diff --git a/ChangeLog.md b/ChangeLog.md
--- a/ChangeLog.md
+++ b/ChangeLog.md
@@ -1,5 +1,11 @@
 # Revision history for reflex-ghci
 
+## 0.2.0.0
+
+* Support ghc 9.6 and reflex-vty 0.4
+* *Breaking change*: Expose a new interface that more robustly represents the state of the child ghci process.
+* Show the most recently run commands and allow user to select from them to see their output
+
 ## 0.1.5.4
 
 * Support ghc 8.10 and reflex-vty 0.3
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # reflex-ghci
 
-[![Haskell](https://img.shields.io/badge/language-Haskell-orange.svg)](https://haskell.org) [![Hackage](https://img.shields.io/hackage/v/reflex-ghci.svg)](https://hackage.haskell.org/package/reflex-ghci) [![Hackage CI](https://matrix.hackage.haskell.org/api/v2/packages/reflex-ghci/badge)](https://matrix.hackage.haskell.org/#/package/reflex-ghci) [![Travis CI](https://api.travis-ci.org/reflex-frp/reflex-ghci.svg?branch=develop)](https://travis-ci.org/reflex-frp/reflex-ghci) [![BSD3 License](https://img.shields.io/badge/license-BSD3-blue.svg)](https://github.com/reflex-frp/reflex-ghci/blob/master/LICENSE)
+[![Haskell](https://img.shields.io/badge/language-Haskell-orange.svg)](https://haskell.org) [![Hackage](https://img.shields.io/hackage/v/reflex-ghci.svg)](https://hackage.haskell.org/package/reflex-ghci) [![BSD3 License](https://img.shields.io/badge/license-BSD3-blue.svg)](https://github.com/reflex-frp/reflex-ghci/blob/master/LICENSE)
 
 Run GHCi from within a [Reflex FRP](https://reflex-frp.org) application and interact with it using a functional reactive interface.
 
diff --git a/reflex-ghci.cabal b/reflex-ghci.cabal
--- a/reflex-ghci.cabal
+++ b/reflex-ghci.cabal
@@ -1,6 +1,6 @@
 cabal-version: 2.4
 name: reflex-ghci
-version: 0.1.5.4
+version: 0.2.0.0
 synopsis: A GHCi widget library for use in reflex applications
 description:
   Run GHCi from within a Reflex FRP (<https://reflex-frp.org>) application and interact with it using a functional reactive interface.
@@ -18,27 +18,31 @@
                     README.md
                     tests/**/*.hs
                     tests/**/*.cabal
-tested-with: GHC ==8.6.5 || ==8.8.4
+tested-with: GHC ==8.6.5 || ==8.8.4 || ==9.6.1
 
 library
   exposed-modules:
     Reflex.Process.GHCi
+    Reflex.Process.Repl
     Reflex.Vty.GHCi
   build-depends:
-      base >= 4.12 && < 4.15
-    , bytestring >= 0.10 && < 0.11
+      base >= 4.12 && < 4.19
+    , bytestring >= 0.10 && < 0.12
+    , containers >= 0.6 && < 0.7
     , directory >= 1.3 && < 1.4
     , filepath >= 1.4 && < 1.5
-    , fsnotify >= 0.3 && < 0.4
+    , fsnotify >= 0.4 && < 0.5
     , process >= 1.6 && < 1.7
-    , reflex >= 0.7.1.0 && < 0.9
-    , reflex-fsnotify >= 0.2 && < 0.3
-    , reflex-process >= 0.3 && < 0.4
+    , reflex >= 0.7.1.0 && < 1
+    , reflex-fsnotify >= 0.3 && < 0.4
+    , reflex-process >= 0.3.2 && < 0.4
     , regex-tdfa >= 1.2.3 && < 1.4
-    , reflex-vty >= 0.3 && < 0.4
-    , text >= 1.2 && < 1.3
-    , unix >= 2.7 && < 2.8
-    , vty >=5.21 && <5.37
+    , reflex-vty >= 0.3 && < 0.5
+    , semialign > 1 && < 2
+    , text >= 1.2 && < 2.1
+    , these > 1 && < 2
+    , unix >= 2.7 && < 2.9
+    , vty >=5.21 && <5.39
   hs-source-dirs: src
   default-language: Haskell2010
   ghc-options: -Wall
@@ -48,9 +52,9 @@
   main-is: ghci.hs
   build-depends:
       base
-    , optparse-applicative >= 0.14.0 && < 0.17
+    , optparse-applicative >= 0.14.0 && < 0.19
     , process
-    , reflex >= 0.7.1.0
+    , reflex
     , reflex-ghci
     , reflex-vty
     , reflex-process
@@ -67,7 +71,10 @@
   hs-source-dirs: tests
   build-depends:
       base
+    , bytestring
+    , containers
     , directory
+    , filepath
     , process
     , reflex
     , reflex-ghci
diff --git a/src-bin/ghci.hs b/src-bin/ghci.hs
--- a/src-bin/ghci.hs
+++ b/src-bin/ghci.hs
@@ -44,14 +44,7 @@
             showVersion version
         ]
   GhciArg { _ghciArg_replCommand = cmd, _ghciArg_execCommand = expr } <- execParser opts
-  mainWidget $ initManager_ $ do
-    exit <- keyCombo (V.KChar 'c', [V.MCtrl])
-    g <- ghciWatch (shell cmd) $ T.encodeUtf8 . T.pack <$> expr
-    case expr of
-      Nothing -> ghciModuleStatus g
-      Just _ -> ghciPanes g
-    readyToExit <- performEvent $ ffor exit $ \_ -> liftIO $ terminateProcess $ _process_handle $ _ghci_process g
-    return $ () <$ readyToExit
+  run cmd expr
 
 -- Some rudimentary test expressions
 -- Run these to test different scenarios like so:
diff --git a/src/Reflex/Process/GHCi.hs b/src/Reflex/Process/GHCi.hs
--- a/src/Reflex/Process/GHCi.hs
+++ b/src/Reflex/Process/GHCi.hs
@@ -4,172 +4,113 @@
 -}
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE LambdaCase #-}
+{-# LANGUAGE MultiWayIf #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecursiveDo #-}
 {-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE StandaloneDeriving #-}
 module Reflex.Process.GHCi
   ( ghci
   , ghciWatch
-  , Ghci(..)
-  , Status(..)
-  , moduleOutput
-  , execOutput
-  , collectOutput
-  , statusMessage
+  , module X
+  , hasErrors
   ) where
 
 import Reflex
 import Reflex.FSNotify (watchDirectoryTree)
-import Reflex.Process (ProcessConfig(..), Process(..), SendPipe(..), createProcess)
+import Reflex.Process (Process(..))
 
-import Control.Monad ((<=<))
+import Control.Monad
 import Control.Monad.Fix (MonadFix)
-import Control.Monad.IO.Class (liftIO, MonadIO)
+import Control.Monad.IO.Class (MonadIO, liftIO)
 import Data.ByteString (ByteString)
 import qualified Data.ByteString.Char8 as C8
-import Data.String (IsString)
+import qualified Data.Map as Map
 import System.Directory (getCurrentDirectory)
 import qualified System.FSNotify as FS
 import System.FilePath.Posix (takeExtension)
 import qualified System.Info as Sys
-import System.Posix.Signals (sigINT)
+import qualified System.Posix.Signals as Signals
 import qualified System.Process as P
 import qualified Text.Regex.TDFA as Regex ((=~))
 
-msg :: (IsString a, Semigroup a) => a -> a
-msg = (<>) "<reflex-ghci>: "
+import Reflex.Process.Repl as X
 
 -- | Runs a GHCi process and reloads it whenever the provided event fires
 ghci
-  :: ( TriggerEvent t m
-     , PerformEvent t m
-     , MonadIO (Performable m)
-     , PostBuild t m
-     , MonadIO m
+  :: ( Adjustable t m
      , MonadFix m
      , MonadHold t m
+     , MonadIO (Performable m)
+     , MonadIO m
+     , NotReady t m
+     , PerformEvent t m
+     , PostBuild t m
+     , TriggerEvent t m
      )
-  => P.CreateProcess
-  -- ^ Command to run to enter GHCi
-  -> Maybe ByteString
-  -- ^ Expression to evaluate whenever GHCi successfully loads modules
-  -> Event t ()
-  -- ^ Ask GHCi to reload
-  -> m (Ghci t)
-ghci cmd mexpr reloadReq = do
-  -- Run the process and feed it some input:
-  let msgInit = msg "performing setup..."
-      msgExprStarted = msg "evaluating expression..."
-      msgExprFinished = msg "expression evaluation ended."
-      putMsgLn :: ByteString -> ByteString
-      putMsgLn m = "Prelude.putStrLn \"" <> m <> "\"\n"
-  rec proc <- createProcess cmd $ ProcessConfig
-        { _processConfig_stdin = SendPipe_Message . (<> "\n") <$> leftmost
-            [ reload
-            -- Execute some expression if GHCi is ready to receive it
-            , fforMaybe (updated status) $ \case
-                Status_LoadSucceeded -> ffor mexpr $ \expr ->
-                  C8.intercalate "\n"
-                    [ putMsgLn msgExprStarted
-                    , putMsgLn expr
-                    , expr
-                    , putMsgLn msgExprFinished
-                    ]
-                _ -> Nothing
-            -- On first load, set the prompt
-            , let f old new = if old == Status_Initializing && new == Status_Loading
-                    then Just $ C8.intercalate "\n"
-                      [ putMsgLn msgInit
-                      , ":set prompt ..."
-                      , ":set -fno-break-on-exception"
-                      , ":set -fno-break-on-error"
-                      , ":set prompt \"\""
-                      , putMsgLn ""
-                      , ":set prompt " <> prompt
-                      , ":r"
-                      ]
-                    else Nothing
-              in attachWithMaybe f (current status) (updated status)
-            ]
-        , _processConfig_signal = sigINT <$ requestInterrupt
-        }
-
-      -- Reload
-      let reload = leftmost
-            [ ":r" <$ reloadReq
-            ]
-
-      -- Capture and accumulate stdout and stderr between reloads.
-      -- We'll inspect these values to determine GHCi's state
-      output <- collectOutput (() <$ reload) $ _process_stdout proc
-      errors <- collectOutput (() <$ reload) $ _process_stderr proc
-
-     -- Only interrupt when there's a file change and we're ready and not in an idle state
-      let interruptible s = s `elem` [Status_Loading, Status_Executing]
-          requestInterrupt = gate (interruptible <$> current status) (() <$ reloadReq)
-
-      -- Define some Regex patterns to use to determine GHCi's state based on output
-      let okModulesLoaded = "Ok.*module.*loaded." :: ByteString
-          failedNoModulesLoaded = "Failed,.*module.*loaded." :: ByteString
-          -- TODO: Is there a way to distinguish GHCi's actual exception output
-          -- from someone printing "*** Exception:" to stderr?
-          -- TODO: Are there other exception patterns to watch out for?
-          exceptionMessage = "\\*\\*\\* Exception:.*" :: ByteString
-          interactiveErrorMessage = "<interactive>:.*:.*:.error:.*" :: ByteString
-          -- We need to know when ghci is initialized enough that it won't die when
-          -- it receives an interrupt. We wait to see the version line in the output as
-          -- a proxy for GHCi's readiness to be interrupted
-          ghciVersionMessage = "GHCi, version.*: https?://www.haskell.org/ghc/" :: ByteString
-
-      -- Inspect the output and determine what state GHCi is in
-      status :: Dynamic t Status <- holdUniqDyn <=< foldDyn ($) Status_Initializing $ leftmost
-        [ fforMaybe (updated errors) $ \err -> if err Regex.=~ exceptionMessage || err Regex.=~ interactiveErrorMessage
-          then Just $ const Status_ExecutionFailed
-          else Nothing
-        , const Status_Loading <$ reload
-        , ffor (updated output) $ \out -> case reverse (C8.lines out) of
-            lastLine:expectedMessage:_
-              | lastLine == prompt && expectedMessage Regex.=~ okModulesLoaded -> const Status_LoadSucceeded
-              | lastLine == prompt && expectedMessage Regex.=~ failedNoModulesLoaded -> const Status_LoadFailed
-              | lastLine == prompt && expectedMessage == msgExprStarted -> const Status_Executing
-              | lastLine Regex.=~ (prompt :: String) && expectedMessage Regex.=~ msgExprFinished -> const Status_ExecutionSucceeded
-              | lastLine Regex.=~ ghciVersionMessage -> const Status_Loading
-              | otherwise -> \case
-                  Status_LoadSucceeded -> case mexpr of
-                    Nothing -> Status_LoadSucceeded
-                    Just _ -> Status_Executing
-                  s -> s
-
-            lastLine:_
-              | lastLine Regex.=~ ghciVersionMessage -> const Status_Loading
-            _ -> id
-        ]
-
-  -- Determine when to switch output stream from GHCi module output to execution output
-  execStream <- hold False $ leftmost
-      [ False <$ reload
-      , fforMaybe (updated status) $ \case
-          Status_LoadSucceeded -> Just True
-          Status_LoadFailed -> Just False
-          Status_Executing -> Just True
-          _ -> Nothing
+  => P.CreateProcess -- ^ How to run GHCi
+  -> Event t [Command] -- ^ Send an expression to evaluate
+  -> Event t () -- ^ Reload
+  -> Event t () -- ^ Shutdown
+  -> m (Repl t)
+ghci runGhci expr reload shutdown = do
+  _ <- liftIO $ Signals.installHandler Signals.sigINT (Signals.Catch (return ())) Nothing
+  pb <- getPostBuild
+  rec
+    r@(Repl proc _finished started exit) <- repl runGhci inputs isPrompt
+    let inputs = mergeWith (<>)
+          [ commands setupCommands <$ pb
+          , command ":r" <$ interrupted
+          , command ":r" <$ reload'
+          , command ":q" <$ shutdown
+          , expr
+          ]
+    let interruptible = ffor started $ \case
+          (_, Nothing) -> False
+          _ -> True
+    -- Don't allow reloads too close together. Sometimes a single change that
+    -- results in a reload might actually cause multiple reload events (e.g.,
+    -- an editor writing a file by deleting and writing it, resulting in two
+    -- filesystem events)
+    reloadThrottled <- throttle 0.1 reload
+    let reload' = gate (not <$> current interruptible) reloadThrottled
+    interrupted <- performEvent $ ffor (gate (current interruptible) reloadThrottled) $ \_ -> do
+      liftIO $ P.interruptProcessGroupOf $ _process_handle proc
+    -- If we can't shut down cleanly within 2 seconds, kill it
+    forceShutdown <- delay 2 shutdown
+    performEvent_ $ ffor forceShutdown $ \_ -> liftIO $ do
+      let h = _process_handle proc
+      P.interruptProcessGroupOf h
+      P.terminateProcess h
+    -- Reinstall the default signal handler after the repl process exits
+    performEvent_ $ ffor exit $ \_ -> liftIO $
+      void $ Signals.installHandler Signals.sigINT Signals.Default Nothing
+  pure r
+  where
+    promptPostfix :: ByteString
+    promptPostfix = "_reflex_ghci_prompt>"
+    isPrompt cur line = (C8.pack (show cur) <> promptPostfix) == line
+    setupCommands =
+      [ ":set prompt-function \\_ x -> let s = \"\\n\" <> show x <> \"" <> promptPostfix <> "\\n\" in System.IO.hPutStr System.IO.stderr s >> pure s"
+      , ":set -fno-break-on-exception"
+      , ":set -fno-break-on-error"
+      , ":set -v1"
+      , ":set -fno-hide-source-paths"
+      , ":set -ferror-spans"
+      , ":set -fdiagnostics-color=never" -- TODO handle ansi escape codes in output
+      , ":r" -- This is here because we might hit errors at load time, before we've had a chance to set up the prompt. This will re-print those errors.
       ]
 
-  -- Below, we split up the output of the GHCi process into things that GHCi itself
-  -- produces (e.g., errors, warnings, loading messages) and the output of the expression
-  -- it is evaluating
-  return $ Ghci
-    { _ghci_moduleOut = gate (not <$> execStream) $ _process_stdout proc
-    , _ghci_moduleErr = gate (not <$> execStream) $ _process_stderr proc
-    , _ghci_execOut = gate execStream $ _process_stdout proc
-    , _ghci_execErr = gate execStream $ _process_stderr proc
-    , _ghci_reload = () <$ reload
-    , _ghci_status = status
-    , _ghci_process = proc
-    }
-  where
-    prompt :: IsString a => a
-    prompt = "<| Waiting |>"
+-- | Detect errors reported in stdout or stderr
+hasErrors :: Cmd -> Bool
+hasErrors (Cmd _ o e) =
+  let errs l =
+        l Regex.=~ exceptionMessage ||
+        l Regex.=~ interactiveErrorMessage ||
+        l Regex.=~ moduleLoadError
+      errOnStderr = any errs $ _lines_terminated e
+      errOnStdout = any (Regex.=~ failedModulesLoaded) $ _lines_terminated o
+  in errOnStderr || errOnStdout
 
 -- | Run a GHCi process that watches for changes to Haskell source files in the
 -- current directory and reloads if they are modified
@@ -181,11 +122,15 @@
      , MonadIO m
      , MonadFix m
      , MonadHold t m
+     , Adjustable t m
+     , NotReady t m
      )
   => P.CreateProcess
-  -> Maybe ByteString
-  -> m (Ghci t)
-ghciWatch p mexec = do
+  -> Maybe Command
+  -> Event t ()
+  -> Event t ()
+  -> m (Repl t)
+ghciWatch p mexpr reload shutdown = do
   -- Get the current directory so we can observe changes in it
   dir <- liftIO getCurrentDirectory
 
@@ -199,9 +144,11 @@
 
   -- On macOS, use the polling backend due to https://github.com/luite/hfsevents/issues/13
   -- TODO check if this is an issue with nixpkgs
-  let fsConfig = noDebounce $ FS.defaultConfig
-        { FS.confUsePolling = Sys.os == "darwin"
-        , FS.confPollInterval = 250000
+  let fsConfig = FS.defaultConfig
+        { FS.confWatchMode =
+            if Sys.os == "darwin"
+              then FS.WatchModePoll 200000
+              else FS.WatchModeOS
         }
   fsEvents <- watchDirectoryTree fsConfig (dir <$ pb) $ \e ->
     takeExtension (FS.eventPath e) `elem` [".hs", ".lhs"]
@@ -209,86 +156,32 @@
   -- Events are batched because otherwise we'd get several updates corresponding to one
   -- user-level change. For example, saving a file in vim results in an event claiming
   -- the file was removed followed almost immediately by an event adding the file
-  batchedFsEvents <- batchOccurrences 0.1 fsEvents
-
-  -- Call GHCi and request a reload every time the files we're watching change
-  ghci p mexec $ () <$ batchedFsEvents
-  where
-    noDebounce :: FS.WatchConfig -> FS.WatchConfig
-    noDebounce cfg = cfg { FS.confDebounce = FS.NoDebounce }
+  batchedFsEvents <- batchOccurrences 0.05 fsEvents
 
--- | The output of the GHCi process
-data Ghci t = Ghci
-  { _ghci_moduleOut :: Event t ByteString
-  -- ^ stdout output produced when loading modules
-  , _ghci_moduleErr :: Event t ByteString
-  -- ^ stderr output produced when loading modules
-  , _ghci_execOut :: Event t ByteString
-  -- ^ stdout output produced while evaluating an expression
-  , _ghci_execErr :: Event t ByteString
-  -- ^ stderr output produced while evaluating an expression
-  , _ghci_reload :: Event t ()
-  -- ^ Event that fires when GHCi is reloading
-  , _ghci_status :: Dynamic t Status
-  -- ^ The current status of the GHCi process
-  , _ghci_process :: Process t ByteString ByteString
-  }
+  -- Call GHCi, request a reload every time the files we're watching change.
+  let reloadEvents = ((() <$ batchedFsEvents) <> reload)
 
--- | The state of the GHCi process
-data Status
-  = Status_Initializing
-  | Status_Loading
-  | Status_LoadFailed
-  | Status_LoadSucceeded
-  | Status_Executing
-  | Status_ExecutionFailed
-  | Status_ExecutionSucceeded
-  deriving (Show, Read, Eq, Ord)
+  rec g <- ghci p sendExpr reloadEvents shutdown
+      sendExpr <- delay 0.1 $ fforMaybe (_repl_finished g) $ \finished -> case reverse (Map.elems finished) of
+            c@(Cmd cmd _ _):_ -> if displayCommand cmd == ":r" && not (hasErrors c)
+              then case mexpr of
+                Nothing -> Nothing
+                Just expr -> Just [expr]
+              else Nothing
+            _ -> Nothing
+  pure g
 
--- | Collect all the GHCi module output (i.e., errors, warnings, etc) and optionally clear
--- every time GHCi reloads
-moduleOutput
-  :: (Reflex t, MonadFix m, MonadHold t m)
-  => Behavior t Bool
-  -- ^ Whether to clear the output on reload
-  -> Ghci t
-  -> m (Dynamic t ByteString)
-moduleOutput clear g = collectOutput
-  (gate clear $ () <$ _ghci_reload g) $
-    leftmost [_ghci_moduleOut g, _ghci_moduleErr g]
+failedModulesLoaded :: ByteString
+failedModulesLoaded = "Failed,.*module.*loaded." :: ByteString
 
--- | Collect all the GHCi expression output (i.e., the output of the called function) and optionally clear
--- every time GHCi reloads
-execOutput
-  :: (Reflex t, MonadFix m, MonadHold t m)
-  => Behavior t Bool
-  -- ^ Whether to clear the output on reload
-  -> Ghci t
-  -> m (Dynamic t ByteString)
-execOutput clear g = collectOutput
-  (gate clear $ () <$ _ghci_reload g) $
-    leftmost [_ghci_execOut g, _ghci_execErr g]
+-- TODO: Is there a way to distinguish GHCi's actual exception output
+-- from someone printing "*** Exception:" to stderr?
+-- TODO: Are there other exception patterns to watch out for?
+exceptionMessage :: ByteString
+exceptionMessage = "\\*\\*\\* Exception:.*" :: ByteString
 
--- | Collect output, appending new output to the end of the accumulator
-collectOutput
-  :: (Reflex t, MonadFix m, MonadHold t m)
-  => Event t ()
-  -- ^ Clear output
-  -> Event t ByteString
-  -- ^ Output to add
-  -> m (Dynamic t ByteString)
-collectOutput clear out = foldDyn ($) "" $ leftmost
-  [ flip mappend <$> out
-  , const "" <$ clear
-  ]
+interactiveErrorMessage :: ByteString
+interactiveErrorMessage = "<interactive>:.*:.*:.error:.*" :: ByteString
 
--- | Describe the current status of GHCi in a human-readable way
-statusMessage :: IsString a => Status -> a
-statusMessage = \case
-  Status_Initializing -> "Initializing..."
-  Status_Loading -> "Loading Modules..."
-  Status_LoadFailed -> "Failed to Load Modules!"
-  Status_LoadSucceeded -> "Successfully Loaded Modules!"
-  Status_Executing -> "Executing Command..."
-  Status_ExecutionFailed -> "Command Failed!"
-  Status_ExecutionSucceeded -> "Command Succeeded!"
+moduleLoadError :: ByteString
+moduleLoadError = "^.+\\.(l)?hs:[0-9]+:[0-9]+: error:$"
diff --git a/src/Reflex/Process/Repl.hs b/src/Reflex/Process/Repl.hs
new file mode 100644
--- /dev/null
+++ b/src/Reflex/Process/Repl.hs
@@ -0,0 +1,404 @@
+{-|
+ - Module: Reflex.Process.Repl
+ - Description: Run repl-like processes in a reflex application.
+-}
+
+{-# Language BangPatterns #-}
+{-# Language FlexibleContexts #-}
+{-# Language FlexibleInstances #-}
+{-# Language LambdaCase #-}
+{-# Language MultiParamTypeClasses #-}
+{-# Language MultiWayIf #-}
+{-# Language OverloadedStrings #-}
+{-# Language RecursiveDo #-}
+{-# Language ScopedTypeVariables #-}
+{-# Language StandaloneDeriving #-}
+{-# Language TupleSections #-}
+module Reflex.Process.Repl
+  ( Repl(..)
+  , Cmd(..)
+  , Accum(..)
+  , accumHandle
+  , accumHandles
+  , flushAccum
+  , repl
+  , Lines(..)
+  , emptyLines
+  , addLines
+  , linesFromBS
+  , unLines
+  , lastWholeLine
+  , splitLinesOn
+  , Command
+  , unsafeCommand
+  , command
+  , commands
+  , displayCommand
+  , sendCommands
+  , testRepl
+  , mkTestCommands
+  , assertStdoutEq
+  , assertStderrEq
+  , assertStderr
+  , assertStdout
+  , assertHandleEq
+  , assertHandle
+  , assertCmd
+  ) where
+
+import Control.Concurrent (forkIO)
+import Control.Monad
+import Control.Monad.Fix (MonadFix)
+import Control.Monad.IO.Class (MonadIO, liftIO)
+import Data.Align (align)
+import Data.ByteString (ByteString)
+import qualified Data.ByteString.Char8 as C8
+import Data.Foldable (toList)
+import Data.IORef
+import qualified Data.Map as Map
+import Data.Map (Map)
+import Data.Maybe (fromMaybe)
+import qualified Data.Sequence as Seq
+import Data.Sequence (Seq)
+import Data.These (These(..))
+import Reflex
+import Reflex.Host.Headless
+import Reflex.Process
+import System.Exit (ExitCode)
+import qualified System.Posix.Signals as Signals
+import qualified System.Process as P
+
+-- * REPL
+
+-- | Output of a 'repl' session
+data Repl t = Repl
+  { _repl_process :: Process t ByteString ByteString
+  -- ^ The underlying repl process, which can be used to access the process handle, the raw output, and so on.
+  , _repl_finished :: Event t (Map Int Cmd)
+  -- ^ An event of commands that the repl has finished executing and their
+  -- associated output. The 'Int' here represents the order in which commands
+  -- were submitted to the repl.
+  , _repl_started :: Dynamic t (Int, Maybe Cmd)
+  -- ^ A dynamic of the currently running command, if any, and its output. The
+  -- 'Int' here represents the ordering of this command relative to the ones in
+  -- '_repl_finished'.
+  , _repl_exited :: Event t ExitCode
+  -- ^ An event that fires when the repl exits
+  }
+
+-- | An individual command submitted to the repl, and its output
+data Cmd = Cmd
+  { _cmd_stdin :: Command
+  , _cmd_stdout :: Lines
+  , _cmd_stderr :: Lines
+  }
+  deriving (Eq, Ord, Show)
+
+-- | An accumulator used to track and combine the repl's output streams 
+data Accum = Accum
+  { _accum_stdout :: (Map Int Lines, Int, Lines)
+  , _accum_stderr :: (Map Int Lines, Int, Lines)
+  }
+
+-- | Accumulate a handle, grouping its output by prompts
+accumHandle
+  :: (Int -> ByteString -> Bool)
+  -> ByteString
+  -> (Map Int Lines, Int, Lines)
+  -> (Map Int Lines, Int, Lines)
+accumHandle isPrompt new (done, cur, l) =
+  let l' = addLines new l
+  in case splitLinesOn (isPrompt (cur + 1)) l' of
+        Nothing -> (done, cur, l')
+        Just (before, after) ->
+          let (newMap, newCur, newLines) = accumHandle isPrompt (unLines after) (Map.insert cur before done, cur+1, mempty)
+          in (Map.union newMap done, newCur, newLines)
+
+-- | Accumulate the output of stdout and stderr, grouping the output lines of both by prompt
+accumHandles
+  :: (Int -> ByteString -> Bool)
+  -> These ByteString ByteString
+  -> Accum
+  -> Accum
+accumHandles isPrompt new acc =
+  let acc' = case new of
+        This a -> acc
+          { _accum_stdout = accumHandle isPrompt a $ _accum_stdout acc
+          }
+        That a -> acc
+          { _accum_stderr = accumHandle isPrompt a $ _accum_stderr acc
+          }
+        These a b -> acc
+          { _accum_stdout = accumHandle isPrompt a $ _accum_stdout acc
+          , _accum_stderr = accumHandle isPrompt b $ _accum_stderr acc
+          }
+      -- This intersection represents the commands/output that we've already had the opportunity to report. Those commands can now be removed.
+      oldIntersection = Map.intersection (fst3 $ _accum_stdout acc) (fst3 $ _accum_stderr acc)
+  in Accum
+      { _accum_stdout =
+          ( (fst3 $ _accum_stdout acc') `Map.difference` oldIntersection -- Only include output of commands we haven't previously declared "done"
+          , snd3 $ _accum_stdout acc'
+          , thd3 $ _accum_stdout acc'
+          )
+      , _accum_stderr =
+          ( (fst3 $ _accum_stderr acc') `Map.difference` oldIntersection -- Only include output of commands we haven't previously declared "done"
+          , snd3 $ _accum_stderr acc'
+          , thd3 $ _accum_stderr acc'
+          )
+      }
+
+-- | Take all the pending output and consider it complete.
+flushAccum :: Accum -> Accum
+flushAccum (Accum (stdout, curout, stdoutLeftovers) (stderr, curerr, stderrLeftovers)) =
+  Accum (Map.insert curout stdoutLeftovers stdout, curout+1, mempty) (Map.insert curerr stderrLeftovers stderr, curerr+1, mempty)
+
+-- | Run a repl, feed it commands, and produce output grouped by those
+-- commands. The repl in question must be able to print its prompt on both
+-- stdout and stderr.
+repl
+  :: forall t m.
+     ( Adjustable t m
+     , MonadFix m
+     , MonadHold t m
+     , MonadIO (Performable m)
+     , MonadIO m
+     , NotReady t m
+     , PerformEvent t m
+     , PostBuild t m
+     , TriggerEvent t m
+     )
+  => P.CreateProcess
+  -- ^ Command to run to enter repl
+  -> Event t [Command]
+  -- ^ Commands to send to the repl
+  -> (Int -> ByteString -> Bool)
+  -- ^ Test for determining whether a line is the prompt we're waiting or
+  -> m (Repl t)
+repl runRepl cmds isPrompt = do
+  let ix0 = 1
+  n <- liftIO $ newIORef ix0
+  newIxedInput <- performEvent $ ffor cmds $ \inputs -> do
+    fmap Map.fromList $ forM inputs $ \input' -> do
+      new_n <- liftIO $ atomicModifyIORef' n $ \n' -> (succ n', n')
+      pure $ (new_n, input')
+  ixedInput <- foldDyn Map.union Map.empty newIxedInput
+  proc <- createProcess runRepl $ ProcessConfig
+    { _processConfig_stdin = fmapMaybe sendCommands cmds
+    , _processConfig_signal = never
+    }
+  pb <- getPostBuild
+  exited <- performEventAsync $ ffor pb $ \_ cb ->
+    liftIO $ void $ forkIO $ cb <=< P.waitForProcess $ _process_handle proc
+  results <- foldDyn ($) (Accum (Map.empty, ix0, mempty) (Map.empty, ix0, mempty)) $ leftmost
+    [ accumHandles isPrompt <$> align (_process_stdout proc) (_process_stderr proc)
+    , flushAccum <$ exited
+    ]
+  let outerr = ffor results $ \(Accum o e) -> Map.intersectionWith (,) (fst3 o) (fst3 e)
+  finished <- holdUniqDyn $ Map.intersectionWith (\inp (o, e) -> Cmd inp o e) <$> ixedInput <*> outerr
+  let commandInProgress i (Accum o e) = (snd3 o,) $ case (Map.lookup (snd3 o) i) of
+        Nothing -> Nothing
+        Just inp -> Just $ Cmd inp (thd3 o) (thd3 e)
+      started = commandInProgress <$> ixedInput <*> results
+  pure $ Repl
+    { _repl_process = proc
+    , _repl_finished = updated finished
+    , _repl_started = started
+    , _repl_exited = exited
+    }
+
+-- * Output lines
+
+-- | Accumulator for line-based output that keeps track of any dangling,
+-- unterminated line
+data Lines = Lines
+  { _lines_terminated :: Seq C8.ByteString
+  , _lines_unterminated :: Maybe C8.ByteString
+  }
+  deriving (Show, Eq, Ord, Read)
+
+-- | Empty output
+emptyLines :: Lines
+emptyLines = Lines Seq.empty Nothing
+
+-- | Add some raw output to a 'Lines'. This will chop the raw output up into lines.
+addLines :: ByteString -> Lines -> Lines
+addLines new (Lines t u) =
+  let newLines = Seq.fromList $ filter (not . C8.null) (C8.lines new)
+  in
+    case u of
+      Nothing -> if "\n" `C8.isSuffixOf` new
+        then Lines (t <> newLines) Nothing
+        else case Seq.viewr newLines of
+                Seq.EmptyR -> Lines t Nothing
+                (t' Seq.:> u') -> Lines (t <> t') (Just u')
+      Just u' -> addLines (u' <> new) $ Lines t Nothing
+
+-- | Convert a 'ByteString' into a 'Lines'
+linesFromBS :: C8.ByteString -> Lines
+linesFromBS = flip addLines mempty
+
+instance Semigroup Lines where
+  a <> b = addLines (unLines b) a
+
+instance Monoid Lines where
+  mempty = emptyLines
+
+-- | Convert a 'Lines' back into a 'ByteString'
+unLines :: Lines -> ByteString
+unLines (Lines t u) =
+  C8.unlines (toList t) <> fromMaybe "" u
+
+-- | Convenience accessor for the last whole line received by a 'Lines'.
+-- Ignores any unterminated line that may follow.
+lastWholeLine :: Lines -> Maybe C8.ByteString
+lastWholeLine (Lines t _) = case Seq.viewr t of
+  Seq.EmptyR -> Nothing
+  _ Seq.:> x -> Just x
+
+-- | Split lines into two. The sequence that satisfies the predicate is
+-- consumed and will not appear in either resulting 'Lines'.
+splitLinesOn :: (ByteString -> Bool) -> Lines -> Maybe (Lines, Lines)
+splitLinesOn test (Lines t u) = 
+  let (before, after) = Seq.breakl test t
+  in if Seq.null after then Nothing else Just (Lines before Nothing, Lines (Seq.drop 1 after) u)
+
+-- * Commands to send to the repl
+
+-- | A string that will be sent to the repl for evaluation. A newline will be
+-- appended to the end of the string. 'Command's should not themselves contain newlines.
+newtype Command = Command { unCommand :: ByteString }
+  deriving (Show, Read, Eq, Ord)
+
+-- | Constructs a 'Command' without checking for newlines. If there are
+-- newlines in the input, things will not work properly.
+unsafeCommand :: ByteString -> Command
+unsafeCommand = Command
+
+-- | Convert a 'ByteString' into a set of 'Command's. Any newlines found in the
+-- input are considered 'Command' separators.
+command :: ByteString -> [Command]
+command = map Command . filter (not . C8.null) . C8.splitWith (=='\n')
+
+-- | Convert a 'ByteString' into a set of 'Command's. Any newlines found in the
+-- input are considered 'Command' separators.
+commands :: [ByteString] -> [Command]
+commands = map Command . filter (not . C8.null) . concatMap (C8.splitWith (=='\n'))
+
+-- | Turn a command back into a 'ByteString'.
+displayCommand :: Command -> ByteString
+displayCommand = unCommand
+
+-- | Convert commands to a format that can be sent over stdin
+sendCommands :: [Command] -> Maybe (SendPipe ByteString)
+sendCommands cmds = case cmds of
+  [] -> Nothing
+  xs -> Just $ SendPipe_Message (C8.intercalate "\n" (unCommand <$> xs) <> "\n")
+
+-- * Misc
+
+fst3 :: (a, b, c) -> a
+fst3 (a, _, _) = a
+
+snd3 :: (a, b, c) -> b
+snd3 (_, b, _) = b
+
+thd3 :: (a, b, c) -> c
+thd3 (_, _, c) = c
+
+-- * Testing
+
+-- | A headless repl test that runs ghci, executes some commands, and checks that the output is as expected.
+testRepl :: IO ()
+testRepl = runHeadlessApp $ do
+  _ <- liftIO $ Signals.installHandler Signals.sigINT (Signals.Catch (return ())) Nothing
+  pb <- getPostBuild
+  testCommands <- mkTestCommands
+  let cmds = mergeWith (\a b -> a <> b)
+        [ command (C8.intercalate "\n"
+            [ ":set prompt-function \\_ x -> let s = \"\\n\" <> show x <> \"" <> promptPostfix <> "\\n\" in System.IO.hPutStr System.IO.stderr s >> pure s"
+            , ":set -fno-break-on-exception"
+            , ":set -fno-break-on-error"
+            , ":set -v1"
+            , ":set -fno-hide-source-paths"
+            , ":set -ferror-spans"
+            , ":set -fdiagnostics-color=never"
+            , ":r"
+            ]) <$ pb
+        , testCommands
+        ]
+  rec (Repl _ finished _ exit) <- repl (P.shell "ghci") cmds $ \cur line -> (C8.pack (show cur) <> promptPostfix) == line
+  output <- foldDyn Map.union Map.empty finished
+  passed <- performEvent $ ffor (tagPromptlyDyn output exit) $ \o -> do
+    liftIO $ print o
+    liftIO $ putStrLn "testRepl:"
+    r1 <- assertStdoutEq "Simple command (1+1)" (Map.lookup 9 o) "2"
+    r2 <- assertStdoutEq "IO action (putStrLn)" (Map.lookup 10 o) "hello"
+    r3 <- assertStderr "Not in scope error" (Map.lookup 11 o) (C8.isInfixOf "Variable not in scope: oops" . unLines)
+    r4 <- assertStdoutEq "Simple command (2+2)" (Map.lookup 12 o) "4"
+    r5 <- assertStdoutEq "Simple command (3+4)" (Map.lookup 13 o) "7"
+    r6 <- assertStderr "Exception" (Map.lookup 14 o) (C8.isInfixOf "*** Exception" . unLines)
+    r7 <- assertStdoutEq "Reload (:r)" (Map.lookup 15 o) "Ok, no modules loaded."
+    r8 <- assertStdoutEq "Quit (:q)" (Map.lookup 16 o) "Leaving GHCi."
+    pure $ and [r1, r2, r3, r4, r5, r6, r7, r8]
+  performEvent $ ffor passed $ \case
+    False -> error "Test failed"
+    True -> pure ()
+  where
+    promptPostfix :: ByteString
+    promptPostfix = "_reflex_ghci_prompt>"
+
+-- | Constructs some testing commands that are fed in on a timer
+mkTestCommands :: (PerformEvent t m, PostBuild t m, TriggerEvent t m, MonadIO (Performable m)) => m (Event t [Command])
+mkTestCommands = do
+  pb <- getPostBuild
+  pb2 <- delay 1 pb
+  pb3 <- delay 1.5 pb
+  pb4 <- delay 2 pb
+  pb5 <- delay 2.5 pb
+  pb6 <- delay 3 pb
+  pure $ fmap command $ mergeWith (\a b -> a <> "\n" <> b)
+    [ "1+1" <$ pb2
+    , "putStrLn \"hello\"" <$ pb3
+    , "oops" <$ pb4
+    , "2+2" <$ pb5
+    , "4+3" <$ pb5
+    , "let Just x = Nothing in print x" <$ pb5
+    , ":r" <$ pb6
+    , ":q" <$ pb6
+    ]
+
+-- | Check that stdout equals the given value
+assertStdoutEq :: MonadIO m => String -> Maybe Cmd -> ByteString -> m Bool
+assertStdoutEq str cmd expectation = assertHandleEq _cmd_stdout str cmd expectation
+
+-- | Check that stderr equals the given value
+assertStderrEq :: MonadIO m => String -> Maybe Cmd -> ByteString -> m Bool
+assertStderrEq str cmd expectation = assertHandleEq _cmd_stderr str cmd expectation
+
+-- | Test the contents of stderr
+assertStderr :: MonadIO m => String -> Maybe Cmd -> (Lines -> Bool) -> m Bool
+assertStderr = assertHandle _cmd_stderr
+
+-- | Test the contents of stdout
+assertStdout :: MonadIO m => String -> Maybe Cmd -> (Lines -> Bool) -> m Bool
+assertStdout = assertHandle _cmd_stdout
+
+-- | Check that a handle equals the given value
+assertHandleEq :: MonadIO m => (Cmd -> Lines) -> String -> Maybe Cmd -> ByteString -> m Bool
+assertHandleEq h str cmd expectation = assertHandle h str cmd (== Lines (Seq.singleton expectation) Nothing)
+
+-- | Test the contents of a handle
+assertHandle :: MonadIO m => (Cmd -> Lines) -> String -> Maybe Cmd -> (Lines -> Bool) -> m Bool
+assertHandle h str cmd expectation = assertCmd str cmd (expectation . h)
+
+-- | Test that a repl command and its output satisfy the predicate
+assertCmd :: MonadIO m => String -> Maybe Cmd -> (Cmd -> Bool) -> m Bool
+assertCmd str cmd expectation = liftIO $ do
+  putStrLn $ "Testing: " <> str
+  if ((expectation <$> cmd) == Just True)
+    then do
+      putStrLn $ "PASSED: " <> str
+      pure True
+    else do
+      putStrLn $ "FAILED: " <> str
+      pure False
diff --git a/src/Reflex/Vty/GHCi.hs b/src/Reflex/Vty/GHCi.hs
--- a/src/Reflex/Vty/GHCi.hs
+++ b/src/Reflex/Vty/GHCi.hs
@@ -3,192 +3,67 @@
  - Description: Vty widgets useful when building your own GHCi runner
 -}
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE MultiWayIf #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE RecursiveDo #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TupleSections #-}
 module Reflex.Vty.GHCi where
 
-import Control.Monad ((<=<), void)
-import Control.Monad.Fix (MonadFix)
-import Control.Monad.IO.Class (liftIO, MonadIO)
-import Data.ByteString (ByteString)
+import Control.Monad
+import qualified Data.Map as Map
+import Data.Maybe
 import qualified Data.Text as T
 import qualified Data.Text.Encoding as T
+import qualified Graphics.Vty.Input as V
 import Reflex.Network
-import Reflex.Process
 import Reflex.Process.GHCi
 import Reflex.Vty
-import qualified Graphics.Vty.Input as V
 import qualified System.Process as P
-
--- | Display the overall status of the GHCi session, including exit information in case GHCi has quit
-statusDisplay
-  :: ( PostBuild t m
-     , MonadHold t m
-     , HasDisplayRegion t m
-     , HasImageWriter t m
-     , HasTheme t m
-     )
-  => Ghci t
-  -> m ()
-statusDisplay g = do
-  pb <- getPostBuild
-  text <=< hold "" $ leftmost
-    [ statusMessage <$> updated (_ghci_status g)
-    , statusMessage <$> tag (current $ _ghci_status g) pb
-    , ("Command exited with " <>) . T.pack . show <$> _process_exit (_ghci_process g)
-    ]
-
--- | A scrollable widget that displays a message at the bottom of the widget
--- when there is additional content to view.
-scrollableOutput
-  :: ( Reflex t
-     , HasDisplayRegion t m
-     , HasFocus t m
-     , HasFocusReader t m
-     , HasImageWriter t m
-     , HasInput t m
-     , HasLayout t m
-     , HasTheme t m
-     , MonadFix m
-     , MonadHold t m
-     , MonadNodeId m
-     , PostBuild t m
-     , HasDisplayRegion t m
-     , HasFocus t m
-     , HasFocusReader t m
-     , HasImageWriter t m
-     , HasInput t m
-     , HasLayout t m
-     )
-  => Behavior t ByteString
-  -> m ()
-scrollableOutput out = col $ do
-  dh <- displayHeight
-  scroll <- tile flex $ scrollableText never $ T.decodeUtf8 <$> out
-  grout (fixed 1) $ text $
-    let f h (ix, n) = if n - ix + 1 > h
-          then "↓ More ↓"
-          else ""
-    in f <$> current dh <*> scroll
-
--- | A scrollable widget that scrolls down as output goes past the end of the widget
-scrollingOutput
-  :: ( Reflex t
-     , Monad m
-     , MonadHold t m
-     , MonadFix m
-     , HasDisplayRegion t m
-     , HasInput t m
-     , HasImageWriter t m
-     , HasTheme t m
-     )
-  => Dynamic t ByteString
-  -> m ()
-scrollingOutput out = do
-  dh <- displayHeight
-  let scrollBy h (ix, n) =
-        if | ix == 0 && n <= h -> Nothing -- Scrolled to the top and we don't have to scroll down
-           | n > h && n - ix - h == 0 -> Just 1
-           | otherwise -> Nothing
-  rec scroll <- scrollableText (tagMaybe (scrollBy <$> current dh <*> scroll) $ updated out) $
-        T.decodeUtf8 <$> current out
-  return ()
-
--- | Display the output GHCi produces when it's loading the requested modules (e.g., warnings)
-ghciModuleStatus
-  :: ( MonadNodeId m
-     , PostBuild t m
-     , MonadHold t m
-     , MonadFix m
-     , Adjustable t m
-     , HasLayout t m
-     , HasImageWriter t m
-     , HasFocusReader t m
-     , HasDisplayRegion t m
-     , HasInput t m
-     , HasTheme t m
-     , HasFocus t m
-     )
-  => Ghci t
-  -> m ()
-ghciModuleStatus g = col $ do
-  let ghciExit = _process_exit $ _ghci_process g
-  ghciExited <- hold False $ True <$ ghciExit
-  grout (fixed 3) $ boxStatic def $ statusDisplay g
-  out <- moduleOutput (not <$> ghciExited) g
-  tile flex $ void $
-    networkHold (scrollableOutput $ current out) $ ffor (_ghci_reload g) $
-      const $ scrollableOutput $ current out
-
--- | Display the output of the expression GHCi is evaluating
-ghciExecOutput
-  :: ( MonadHold t m
-     , MonadFix m
-     , Adjustable t m
-     , HasDisplayRegion t m
-     , HasInput t m
-     , HasImageWriter t m
-     , HasTheme t m
-     , HasInput t m
-     )
-  => Ghci t
-  -> m ()
-ghciExecOutput g = do
-  ghciExited <- hold False $ True <$ _process_exit (_ghci_process g)
-  out <- execOutput (not <$> ghciExited) g
-  -- Rebuild the entire output widget so that we don't have to worry about resetting scroll state
-  _ <- networkHold (scrollingOutput out) $ ffor (_ghci_reload g) $ \_ -> scrollingOutput out
-  return ()
-
--- | A widget that displays the module status and the execution status in two stacked, resizable panes
-ghciPanes
-  :: ( Reflex t
-     , MonadFix m
-     , MonadHold t m
-     , MonadNodeId m
-     , PostBuild t m
-     , Adjustable t m
-     , HasInput t m
-     , HasImageWriter t m
-     , HasFocusReader t m
-     , HasDisplayRegion t m
-     , HasTheme t m
-     , HasLayout t m
-     , HasFocus t m
-     )
-  => Ghci t
-  -> m ()
-ghciPanes g = void $ splitVDrag
-  (hRule doubleBoxStyle)
-  (ghciModuleStatus g)
-  (ghciExecOutput g)
+import qualified Reflex.Process.Repl as Repl
 
--- | Listen for ctrl-c (and any other provided exit events) and
--- shutdown the Ghci process upon receipt
-getExitEvent
-  :: ( PerformEvent t m
-     , MonadIO (Performable m)
-     , HasInput t m
-     )
-  => Ghci t
-  -> Event t a
-  -> m (Event t ())
-getExitEvent g externalExitReq = do
-  exitReq <- keyCombo (V.KChar 'c', [V.MCtrl])
-  let exitReqs = leftmost
-        [ g <$ externalExitReq
-        , g <$ exitReq
+-- | The main reflex-ghci widget
+run :: String -> Maybe String -> IO ()
+run cmd expr = mainWidget $ initManager_ $ do
+  tabNavigation
+  exit' <- keyCombo (V.KChar 'c', [V.MCtrl])
+  exit <- keyCombo (V.KChar 'x', [V.MCtrl])
+  rec Repl _ finished started readyToExit <- ghciWatch (P.shell cmd) (Repl.unsafeCommand . T.encodeUtf8 . T.pack <$> expr) reload ((() <$ exit) <> (() <$ exit') <> quit)
+      oldCommands <- foldDyn ($) Map.empty $
+        (\new old -> Map.fromList $ take 3 $ reverse $ Map.toList $ Map.union old new) <$> finished
+      command <- holdDyn Nothing $ fmap Just $ leftmost
+        [ fforMaybe (updated started) $ \(ix, x) -> (ix,) <$> x
+        , fmapMaybe id $ fmap fst . Map.maxViewWithKey <$> finished
         ]
-  shutdown exitReqs
+      let atPrompt = ffor started $ isNothing . snd
+          errors = maybe False (hasErrors . snd) <$> command
+      (reload, quit) <- col $ do
+        r <- tile (fixed 3) $ row $ do
+          grout flex $ boxStatic def $ text $ (\x -> if x then "Ready" else "Busy") <$> current atPrompt
+          void $ networkView $ ffor errors $ \case
+            True -> grout flex $ boxStatic def $ text "Error!"
+            False -> grout flex $ boxStatic def $ text "All Good!"
+          r <- tile flex $ button def $ text "Reload"
+          q <- tile flex $ button def $ text "Quit"
+          pure (r, q)
+        let cmdbtn ix c = tile (fixed 3) $ button def $ text $ pure $
+              T.pack (show ix) <> ": " <> (T.decodeUtf8 $ displayCommand $ _cmd_stdin c)
+        oldE <- switchHold never <=< fmap (fmap leftmost) $ networkView $ ffor oldCommands $ \old -> forM (Map.toList old) $ \(ix, c) -> do
+          go <- cmdbtn ix c
+          pure $ ix <$ go
+        currentCommand <- switchHold never <=< networkView $ ffor started $ \case
+          (_, Nothing) -> pure never
+          (ix, Just c) -> cmdbtn ix c
 
--- | Shut down a given Ghci process
-shutdown
-  :: ( PerformEvent t m
-     , MonadIO (Performable m)
-     )
-  => Event t (Ghci t)
-  -> m (Event t ())
-shutdown exitReqs = do
-  performEvent $ ffor exitReqs $ \g ->
-    liftIO $ P.terminateProcess $ _process_handle $ _ghci_process g
+        let showOutput (Cmd _ out err) = do
+              _ <- tile flex $ boxStatic def $ scrollableText never $ pure $ T.decodeUtf8 . unLines $ out
+              _ <- tile flex $ boxStatic def $ scrollableText never $ pure $ T.decodeUtf8 . unLines $ err
+              pure ()
+
+        void $ networkHold (void $ networkView $ maybe blank (showOutput . snd) <$> command) $ leftmost
+          [ ffor (attachWithMaybe (flip Map.lookup) (current oldCommands) oldE) showOutput
+          , ffor (attachWithMaybe (\a _ -> fmap snd a) (current command) currentCommand) showOutput
+          ]
+        pure r
+  return $ () <$ readyToExit
diff --git a/tests/lib-pkg-name/Setup.hs b/tests/lib-pkg-name/Setup.hs
new file mode 100644
--- /dev/null
+++ b/tests/lib-pkg-name/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/tests/lib-pkg-name/src/MyLib.hs b/tests/lib-pkg-name/src/MyLib.hs
new file mode 100644
--- /dev/null
+++ b/tests/lib-pkg-name/src/MyLib.hs
@@ -0,0 +1,8 @@
+module MyLib (someFunc) where
+
+import MyLib.One
+import MyLib.Two
+import MyLib.Three
+
+someFunc :: IO ()
+someFunc = putStrLn "someFunc"
diff --git a/tests/lib-pkg-name/src/MyLib/One.hs b/tests/lib-pkg-name/src/MyLib/One.hs
new file mode 100644
--- /dev/null
+++ b/tests/lib-pkg-name/src/MyLib/One.hs
@@ -0,0 +1,4 @@
+module MyLib.One where
+
+data One = One
+  deriving Show
diff --git a/tests/lib-pkg-name/src/MyLib/Three.hs b/tests/lib-pkg-name/src/MyLib/Three.hs
new file mode 100644
--- /dev/null
+++ b/tests/lib-pkg-name/src/MyLib/Three.hs
@@ -0,0 +1,4 @@
+module MyLib.Four where
+
+data Three = Three
+  deriving Show
diff --git a/tests/lib-pkg-name/src/MyLib/Two.hs b/tests/lib-pkg-name/src/MyLib/Two.hs
new file mode 100644
--- /dev/null
+++ b/tests/lib-pkg-name/src/MyLib/Two.hs
@@ -0,0 +1,4 @@
+module MyLib.Two where
+
+data Two = Two
+  deriving Show
diff --git a/tests/lib-pkg-name/test-pkg2.cabal b/tests/lib-pkg-name/test-pkg2.cabal
new file mode 100644
--- /dev/null
+++ b/tests/lib-pkg-name/test-pkg2.cabal
@@ -0,0 +1,28 @@
+cabal-version:       >=1.10
+-- Initial package description 'test-pkg2.cabal' generated by 'cabal init'.
+--   For further documentation, see http://haskell.org/cabal/users-guide/
+
+name:                test-pkg2
+version:             0.1.0.0
+-- synopsis:
+-- description:
+-- bug-reports:
+license:             BSD3
+license-file:        LICENSE
+author:              Ali Abrar
+maintainer:          aliabrar@gmail.com
+-- copyright:
+-- category:
+build-type:          Simple
+extra-source-files:  CHANGELOG.md
+
+library
+  exposed-modules:     MyLib
+                       MyLib.One
+                       MyLib.Two
+                       MyLib.Three
+  -- other-modules:
+  -- other-extensions:
+  build-depends:       base
+  hs-source-dirs: src
+  default-language:    Haskell2010
diff --git a/tests/lib-pkg/src/MyLib.hs b/tests/lib-pkg/src/MyLib.hs
--- a/tests/lib-pkg/src/MyLib.hs
+++ b/tests/lib-pkg/src/MyLib.hs
@@ -1,4 +1,4 @@
-module MyLib (someFunc) where
+module MyLib where
 
 import MyLib.One
 import MyLib.Two
diff --git a/tests/test.hs b/tests/test.hs
--- a/tests/test.hs
+++ b/tests/test.hs
@@ -1,20 +1,23 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE FlexibleInstances #-}
+{-# LANGUAGE MultiParamTypeClasses #-}
 {-# LANGUAGE LambdaCase #-}
 {-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE RecursiveDo #-}
 {-# LANGUAGE ScopedTypeVariables #-}
-
+{-# LANGUAGE StandaloneDeriving #-}
 
-import Control.Monad.Fix (MonadFix)
-import Control.Monad.IO.Class (MonadIO, liftIO)
+import Control.Monad
+import Control.Monad.IO.Class (liftIO)
+import qualified Data.Map as Map
 import Reflex
 import Reflex.Host.Headless (runHeadlessApp)
-import Reflex.Workflow (Workflow (..), workflow)
 import System.Directory (copyFile, getCurrentDirectory, withCurrentDirectory)
 import System.IO.Temp (withSystemTempDirectory)
 import qualified System.Process as P
+import qualified Data.ByteString.Char8 as C8
 
 import Reflex.Process.GHCi
-import Reflex.Vty.GHCi
 
 ghciExe :: FilePath
 ghciExe = "ghci"
@@ -22,222 +25,58 @@
 data ExitStatus = Succeeded | Failed String
   deriving (Eq, Show)
 
--- Simple tests with no reloading or interrupts:
---
--- | Test                 | Module Status         | Expression Status         |
--- |----------------------|-----------------------|---------------------------|
--- | testModuleLoad       | Status_LoadSucceeded  | None                      |
--- | testModuleLoadFailed | Status_LoadFailed     | None                      |
--- | testExprErr          | Status_LoadSucceeded  | Status_ExecutionFailed    |
--- | testExprNotFound     | Status_LoadSucceeded  | Status_ExecutionFailed    |
--- | testExprFinished     | Status_LoadSucceeded  | Status_ExecutionSucceeded |
-
 main :: IO ()
 main = do
-  src <- getCurrentDirectory
-  let cmd path load = P.proc ghciExe ["-i" <> src <> path, load]
-  putStrLn "Testing lib-pkg"
-  testLoadAndExecute $ cmd "/tests/lib-pkg/src" "MyLib"
-  putStrLn "Testing lib-exe"
-  testLoadAndExecute $ cmd "/tests/exe-pkg" "Main"
-  putStrLn "Testing lib-pkg-err"
   runHeadlessApp $ do
-    out <- testModuleLoadFailed $ cmd "/tests/lib-pkg-err/src" "MyLib"
-    failOnError out
-    exitOnSuccess out
-  putStrLn "Testing file watching and reloading"
+    pb <- getPostBuild
+    rec
+      r <- ghci (P.shell ghciExe) expr never never
+      let expr = commands 
+            [ ":l tests/exe-pkg/Main.hs"
+            , "main"
+            , ":l tests/lib-pkg-err/src/MyLib/One.hs tests/lib-pkg-err/src/MyLib/Two.hs tests/lib-pkg-err/src/MyLib/Three.hs tests/lib-pkg-err/src/MyLib.hs"
+            , ":l tests/lib-pkg/src/MyLib/One.hs tests/lib-pkg/src/MyLib/Two.hs tests/lib-pkg/src/MyLib/Three.hs tests/lib-pkg/src/MyLib.hs"
+            , "import MyLib"
+            , "err"
+            , "notFound"
+            , "done"
+            , ":q"
+            ] <$ pb
+      results <- foldDyn Map.union Map.empty $ _repl_finished r
+      performEvent_ $ ffor (tagPromptlyDyn results (_repl_exited r)) $ \o -> do
+        liftIO $ print o
+        liftIO $ putStrLn "Simple tests with no reloading or interrupts:"
+        r1 <- assertStdoutEq "Load and run package (exe-pkg/Main.hs:main)" (Map.lookup 10 o) "Hello, Haskell!"
+        r2 <- assertStderr "Fail to load package (lib-pkg-err)" (Map.lookup 11 o) (C8.isInfixOf "Multiple declarations" . unLines)
+        r3 <- assertStdout "Load package (lib-pkg)" (Map.lookup 12 o) (C8.isInfixOf "Ok, four modules loaded" . unLines)
+        r4 <- assertCmd "Exception (lib-pkg/src/MyLib.hs:err)" (Map.lookup 14 o) hasErrors
+        r5 <- assertCmd "Variable not in scope (lib-pkg/src/MyLib.hs:notFound)" (Map.lookup 15 o) hasErrors
+        r6 <- assertStdoutEq "Expression completed (lib-pkg/src/MyLib.hs:done)" (Map.lookup 16 o) "done"
+        when (not . and $ [r1, r2, r3, r4, r5, r6]) $ error "Tests failed"
+    pure $ () <$ _repl_exited r
+  testRepl
   watchAndReloadTest
 
-failOnError
-  :: ( PerformEvent t m
-     , Reflex t
-     )
-  => Event t ExitStatus -> m ()
-failOnError out = performEvent_ $ fforMaybe out $ \case
-  Failed err -> Just $ error $ "Failed with: " <> err
-  Succeeded -> Nothing
-
-exitOnSuccess
-  :: ( Reflex t
-     , Monad m
-     )
-  => Event t ExitStatus
-  -> m (Event t ())
-exitOnSuccess out =
-  return $ () <$ ffilter (==Succeeded) out
-
-testLoadAndExecute
-  :: P.CreateProcess
-  -> IO ()
-testLoadAndExecute cmd = runHeadlessApp $ do
-  out <- switch . current <$> workflow (testModuleLoad cmd)
-  performEvent_ $ liftIO . print <$> out
-  failOnError out
-  exitOnSuccess out
-
-type TestWorkflow t m = Workflow t m (Event t ExitStatus)
-
-testModuleLoad
-  :: ( MonadIO m
-     , TriggerEvent t m
-     , PerformEvent t m
-     , MonadIO (Performable m)
-     , PostBuild t m
-     , MonadFix m
-     , MonadHold t m
-     )
-  => P.CreateProcess
-  -> TestWorkflow t m
-testModuleLoad cmd = Workflow $ do
-  liftIO $ putStrLn "testModuleLoad"
-  g <- ghciWatch cmd Nothing
-  let loaded = fforMaybe (updated $ _ghci_status g) $ \case
-        Status_LoadSucceeded -> Just True
-        Status_LoadFailed -> Just False
-        _ -> Nothing
-  done <- afterShutdown g loaded
-  return ( Failed . show <$> ffilter not done
-         , testExprErr cmd <$ ffilter id done
-         )
-
-testExprErr
-  :: ( MonadIO m
-     , TriggerEvent t m
-     , PerformEvent t m
-     , MonadIO (Performable m)
-     , PostBuild t m
-     , MonadFix m
-     , MonadHold t m
-     )
-  => P.CreateProcess
-  -> TestWorkflow t m
-testExprErr cmd = Workflow $ do
-  liftIO $ putStrLn "testExprErr"
-  g <- ghciWatch cmd $ Just "err"
-  let exception = fforMaybe (updated $ _ghci_status g) $ \case
-        Status_ExecutionFailed -> Just True
-        Status_ExecutionSucceeded -> Just False
-        _ -> Nothing
-  done <- afterShutdown g exception
-  return ( Failed . show <$> ffilter not done
-         , testExprNotFound cmd <$ ffilter id done
-         )
-
-testExprNotFound
-  :: ( MonadIO m
-     , TriggerEvent t m
-     , PerformEvent t m
-     , MonadIO (Performable m)
-     , PostBuild t m
-     , MonadFix m
-     , MonadHold t m
-     )
-  => P.CreateProcess
-  -> TestWorkflow t m
-testExprNotFound cmd = Workflow $ do
-  liftIO $ putStrLn "testExprNotFound"
-  g <- ghciWatch cmd $ Just "notTheFunctionYoureLookingFor"
-  let exception = fforMaybe (updated $ _ghci_status g) $ \case
-        Status_ExecutionFailed -> Just True
-        Status_ExecutionSucceeded -> Just False
-        _ -> Nothing
-  done <- afterShutdown g exception
-  return ( Failed . show <$> ffilter not done
-         , testExprFinished cmd <$ ffilter id done
-         )
-
-testExprFinished
-  :: ( MonadIO m
-     , TriggerEvent t m
-     , PerformEvent t m
-     , MonadIO (Performable m)
-     , PostBuild t m
-     , MonadFix m
-     , MonadHold t m
-     )
-  => P.CreateProcess
-  -> TestWorkflow t m
-testExprFinished cmd = Workflow $ do
-  liftIO $ putStrLn "testExprFinished"
-  g <- ghciWatch cmd $ Just "done"
-  let finished = fforMaybe (updated $ _ghci_status g) $ \case
-        Status_ExecutionFailed -> Just False
-        Status_ExecutionSucceeded -> Just True
-        _ -> Nothing
-  done <- afterShutdown g finished
-  return ( outcome done
-         , never
-         )
-
-outcome :: Functor f => f Bool -> f ExitStatus
-outcome = fmap (\x -> if x then Succeeded else Failed (show x))
-
 watchAndReloadTest :: IO ()
 watchAndReloadTest = withSystemTempDirectory "reflex-ghci-test" $ \p -> do
+  putStrLn "watchAndReloadTest:"
   src <- getCurrentDirectory
   P.callProcess "cp" ["-r", src <> "/tests/lib-pkg-err", p]
   let cmd = P.proc ghciExe ["-i" <> p <> "/lib-pkg-err/src", "MyLib"]
   withCurrentDirectory p $ runHeadlessApp $ do
-    g <- ghciWatch cmd Nothing
-    performEvent_ $ ffor (_ghci_moduleOut g) $ liftIO . print
-    performEvent_ $ ffor (_ghci_moduleErr g) $ liftIO . print
-    performEvent_ $ ffor (updated $ _ghci_status g) $ liftIO . print
-    let loadFailed = fforMaybe (updated $ _ghci_status g) $ \case
-          Status_LoadFailed -> Just ()
-          _ -> Nothing
+    g <- ghciWatch cmd (Just (unsafeCommand ":q")) never never
+    loadFailedDelayed <- debounce 0.5 $ ffilter id $ updated $ ffor (_repl_started g) $ \case
+      (_, Nothing) -> True
+      _ -> False
 
-    loadFailedDelayed <- delay 1 loadFailed -- If we respond too quickly, fsnotify won't deliver the change.
     performEvent_ $ ffor loadFailedDelayed $ \_ -> liftIO $ do
       putStrLn "copying fixed file"
       copyFile (src <> "/tests/lib-pkg/src/MyLib/Three.hs")
                (p <> "/lib-pkg-err/src/MyLib/Three.hs")
 
-    numFailures :: Dynamic t Int <- count loadFailed
-    let loadSucceeded = fforMaybe (updated $ _ghci_status g) $ \case
-          Status_LoadSucceeded -> Just ()
-          _ -> Nothing
-    performEvent_ $ fforMaybe (updated numFailures) $ \x ->
-      if x > 1
-        then Just $ error "Too many failures."
-        else Nothing
-
-    afterShutdown g $ gate ((>= 1) <$> current numFailures) loadSucceeded
-
-testModuleLoadFailed
-  :: ( MonadIO m
-     , TriggerEvent t m
-     , PerformEvent t m
-     , MonadIO (Performable m)
-     , PostBuild t m
-     , MonadFix m
-     , MonadHold t m
-     )
-  => P.CreateProcess
-  -> m (Event t ExitStatus)
-testModuleLoadFailed cmd = do
-  liftIO $ putStrLn "testModuleLoadFailed"
-  g <- ghciWatch cmd Nothing
-  let loaded = fforMaybe (updated $ _ghci_status g) $ \case
-        Status_LoadSucceeded -> Just False
-        Status_LoadFailed -> Just True
-        _ -> Nothing
-  done <- afterShutdown g loaded
-  return $ ffor done $ \x ->
-    if x
-      then Succeeded
-      else Failed "testModuleFailed: lib-pkg-err shouldn't have loaded"
-
--- | Utility to help use an 'Event' for stopping the network, but not until the shutdown is complete.
-afterShutdown :: (PerformEvent t m, MonadIO (Performable m), MonadHold t m) => Ghci t -> Event t b -> m (Event t b)
-afterShutdown g e = do
-  eValue <- hold Nothing $ Just <$> e
-  after <- shutdown $ g <$ e
-  pure $ fmapMaybe id $ tag eValue after
+    output <- foldDyn Map.union Map.empty $ _repl_finished g
 
-debug :: (Reflex t, PerformEvent t m, MonadIO (Performable m)) => Ghci t -> m ()
-debug g = do
-  performEvent_ $ ffor (updated $ _ghci_status g) $ liftIO . print
-  performEvent_ $ ffor (_ghci_moduleOut g) $ liftIO . print
-  performEvent_ $ ffor (_ghci_moduleErr g) $ liftIO . print
-  performEvent_ $ ffor (_ghci_execOut g) $ liftIO . print
-  performEvent_ $ ffor (_ghci_execErr g) $ liftIO . print
+    performEvent $ ffor (tagPromptlyDyn output (_repl_exited g)) $ \o -> do
+      r1 <- assertCmd "Failed to load file" (Map.lookup 8 o) hasErrors
+      r2 <- assertCmd "Changed file detected and loaded" (Map.lookup 9 o) (not . hasErrors)
+      when (not . and $ [r1, r2]) $ error "Test failed"
