diff --git a/Base.hs b/Base.hs
--- a/Base.hs
+++ b/Base.hs
@@ -1,5 +1,3 @@
-{-# LANGUAGE CPP #-}
-
 -- Copyright (c) 2020-2026 Galen Huntington
 -- SPDX-License-Identifier: GPL-2.0-or-later
 
@@ -21,6 +19,7 @@
 import Data.IORef as X
 import Data.List as X hiding ((!?))
 import Data.Maybe as X
+import Data.Sequence as X (Seq, (<|), (|>))
 import Data.String as X
 import Data.Traversable as X
 import Data.Version as X
@@ -31,6 +30,8 @@
 import System.IO.Unsafe as X
 import Text.Printf as X
 import Text.Read as X (readMaybe)
+import Text.Regex.Posix (match, makeRegexOptsM, compIgnoreCase, compExtended)
+
 import System.Clock
 
 
@@ -40,12 +41,7 @@
 discardErrors = X.handle @SomeException (\_ -> pure ())
 
 getMonoTime :: IO TimeSpec
-getMonoTime = getTime
-#if linux_HOST_OS
-    Boottime
-#else
-    Monotonic
-#endif
+getMonoTime = getTime Monotonic
 
 whenJust :: Monad m => Maybe a -> (a -> m ()) -> m ()
 whenJust = flip $ maybe $ pure ()
@@ -53,4 +49,42 @@
 -- Compatibility: List.!? only added in GHC 9.8
 (!?) :: [a] -> Int -> Maybe a
 xs !? n = listToMaybe $ drop n xs
+
+
+-- API for searching
+matches :: ByteString -> ByteString -> Bool
+
+-- Layer allowing switching back end
+
+{-
+-- pcre-light version (can't use currently due to pcre3 dep)
+matches s = case compileM s [caseless, utf8] of
+    Right p -> \t -> isJust $ match p t []
+    _       -> const False
+-}
+
+{-
+-- regex-pcre2 version (fails to build in CI, not in Stackage)
+matches s = case makeRegexOptsM compCaseless 0 s of
+    Just p -> match p
+    _      -> const False
+-}
+
+{-
+-- pcre2 version (inefficient, mass Text conversion, ugly)
+-- needs text dep/import
+matches s =
+    let p = decodeUtf8Lenient s
+    in \t -> unsafePerformIO
+        $ handle @SomeException (const $ pure False) $ evaluate
+        $ matchesOpt Caseless p (decodeUtf8Lenient t)
+-}
+
+-- regex-posix version (reputed to be slow and buggy)
+matches s = maybe (const False) match $
+    makeRegexOptsM (compIgnoreCase + compExtended) 0 s
+
+-- not yet tried:
+-- regex-tdfa (mass Text conversion, parsec dep) text import
+-- regex-dfa (not in Stackage, unknown engine)
 
diff --git a/Config.hs b/Config.hs
--- a/Config.hs
+++ b/Config.hs
@@ -1,10 +1,10 @@
 -- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
--- Copyright (c) 2019-2021 Galen Huntington
+-- Copyright (c) 2019-2021, 2026 Galen Huntington
 -- SPDX-License-Identifier: GPL-2.0-or-later
 
 module Config where
 
-import qualified Data.Map as M
+import Data.Map qualified as M
 
 import Base
 import Style
diff --git a/Core.hs b/Core.hs
--- a/Core.hs
+++ b/Core.hs
@@ -1,5 +1,3 @@
-{-# LANGUAGE CPP #-}
-
 -- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
 -- Copyright (c) 2008, 2019-2026 Galen Huntington
 -- SPDX-License-Identifier: GPL-2.0-or-later
@@ -26,24 +24,22 @@
 
 import Base
 
-import Syntax
-import Lexer (mpgParser)
+import Decoder
 import State
 import Style
-import Tree hiding (File, Dir)
-import qualified Tree (File,Dir)
-import qualified UI
+import Playlist
+import UI qualified
 
-import qualified Data.ByteString.Char8 as P
-import qualified Data.Sequence as Seq
+import Data.ByteString.Char8 qualified as P
+import Data.Sequence qualified as Seq
 
-import Data.Array               ((!), bounds, Array)
+import Data.Array               ((!), Array)
 import Data.Proxy
 import Data.Tuple               (swap)
 import Control.Monad.State.Strict
 import System.Directory         (doesFileExist, findExecutable, createDirectoryIfMissing,
                                  getXdgDirectory, XdgDirectory(..))
-import System.IO                (hPutStrLn, hGetLine, stderr)
+import System.IO                (hPutStrLn, stderr)
 import System.Process           (runInteractiveProcess, waitForProcess)
 import System.Clock             (TimeSpec(..), diffTimeSpec)
 import System.Random            (randomR, newStdGen)
@@ -52,16 +48,9 @@
 
 import System.Posix.Process     (exitImmediately)
 
-import Text.Regex.PCRE.Light
 
-
 mp3Tool :: String
-mp3Tool =
-#ifdef MPG321
-    "mpg321"
-#else
-    "mpg123"
-#endif
+mp3Tool = "mpg123"
 
 ------------------------------------------------------------------------
 
@@ -69,32 +58,31 @@
 data Options = Options
     { optPaused     :: !Bool             -- ^ start in a paused state
     , optConfigPath :: !(Maybe FilePath) -- ^ override the style.conf location
+    , optPlayMode   :: Maybe Mode        -- ^ play mode
+    , optHistSize   :: Int               -- ^ history size
     }
 
 -- | Sets up state, spawns sub-threads, and starts player.
-start :: Options -> Tree -> IO ()
-start opts (Tree folders music) = do
+start :: Options -> Playlist -> IO ()
+start opts (Playlist folders music) = do
 
     config <- catch @SomeException UI.start \err -> do
         -- An uncaught exception here would deadlock.
         -- XXX more state model revisions should obviate need
         hPutStrLn stderr $ "Curses failed to start: " ++ show err
-        exitImmediately (ExitFailure 1) *> error "Unix <2.8"
+        exitImmediately $ ExitFailure 1
     bootTime <- getMonoTime
     let size = length music
-    mode <- readState
+    mode <- maybe readState pure (optPlayMode opts)
     gen <- newStdGen
     let (current, randomGen) =
             if mode == Random then randomR (0, size-1) gen else (0, gen)
 
     threads <- traverse forkIO
         [ mpgLoop
-        , mpgInput readh
+        , mpgInput
         , refreshLoop
-        , clockLoop
         , uptimeLoop
-        -- mpg321 uses stderr for @F messages
-        , if mp3Tool == "mpg321" then mpgInput errh else errorLoop
         ]
 
     putMVar hState HState
@@ -114,11 +102,11 @@
         , clock        = Nothing
         , info         = Nothing
         , id3          = Nothing
-        , regex        = Nothing
         , modal        = Nothing
         , playHist     = mempty
         , searchHist   = []
-        , clockUpdate  = False
+        , searchFw     = True
+        , histSize     = optHistSize opts
         , miniFocused  = False
         , exiting      = False
         , status       = Stopped
@@ -167,12 +155,12 @@
     case mmpg of
       Nothing     -> shutdown $ Just $ "Cannot find " ++ mp3Tool ++ " in path"
       Just mppath -> do
-        mv <- try $ runInteractiveProcess mppath ["-R", "-"] Nothing Nothing
+        mv <- try $ runInteractiveProcess mppath ["-R", "--remote-err"] Nothing Nothing
         case mv of
           Left (ex :: SomeException) ->
             warnA $ mppath ++ " failed to start; retrying: " ++ show ex
 
-          Right (writeh, readh, errh, pid) -> do
+          Right (writeh, _, errh, pid) -> do
             ct <- modifyHS $ \st -> let sp = spawns st + 1 in (st
                 { mpgPid    = Just pid
                 , status    = Stopped
@@ -181,7 +169,7 @@
                 , spawns    = sp
                 }, sp)
 
-            putMVar mpg Mpg { readh, errh, writeh }
+            putMVar mpg Mpg { errh, writeh }
 
             when (ct > 1) $ warnA $ mp3Tool ++ " #" ++ show ct ++ ": Ready"
             catch @SomeException (void $ waitForProcess pid) (const $ pure ())
@@ -238,26 +226,13 @@
 
 ------------------------------------------------------------------------
 
--- | Periodically wake up and redraw the clock
-clockLoop :: IO ()
-clockLoop = runForever $ threadDelay 125_000 *> UI.refreshClock
-
-------------------------------------------------------------------------
-
--- | Handle, and display errors produced by mpg123
-errorLoop :: IO ()
-errorLoop = runForever $
-    readMVar mpg <&> errh >>= hGetLine >>= (warnA . ("mpg123 err: " ++))
-
-------------------------------------------------------------------------
-
 -- | Handle messages arriving over a pipe from the decoder process. When
 -- shutdown kills the other end of the pipe, hGetLine will fail, so we
 -- take that chance to exit.
 --
-mpgInput :: (Mpg -> Handle) -> IO ()
-mpgInput field = runForever $ do
-    line <- P.hGetLine =<< field <$> readMVar mpg
+mpgInput :: IO ()
+mpgInput = runForever $ do
+    line <- P.hGetLine =<< errh <$> readMVar mpg
     case mpgParser line of
         Right m       -> handleMsg m
         Left (Just e) -> warnA ("mpg123: " ++ e)
@@ -266,17 +241,16 @@
 ------------------------------------------------------------------------
 
 -- | Close most things. Important to do all the jobs:
--- TODO maybe releaseSignals here in case mpg is frozen?
---   and/or move UI.end up?
+-- TODO maybe releaseSignals here?
 shutdown :: Maybe String -> IO ()
 shutdown ms = do
+    UI.end
     silentlyModifyHS $ \st -> st { exiting = True }
     discardErrors writeState
     mpid <- getsHS mpgPid
     whenJust mpid \pid -> do
         discardErrors $ sendMpg Quit
         void $ waitForProcess pid
-    UI.end
     exitImmediately =<< case ms of
         Just s -> hPutStrLn stderr s *> pure (ExitFailure 1)
         _      -> pure ExitSuccess
@@ -288,17 +262,17 @@
 --
 handleMsg :: Msg -> IO ()
 
-handleMsg (T _)   = pure ()
-handleMsg (I i)   = modifyHS_ $ \s -> s { info = Just i }
-handleMsg (F id3) = modifyHS_ $ \s -> s { id3 = Just id3 }
+handleMsg (S i)   = modifyHS_ $ \s -> s { info = Just i }
 
-handleMsg (S t) = do
+handleMsg (I id3) = modifyHS_ $ \s -> s { id3 = Just id3 }
+
+handleMsg (P t) = do
     modifyHS_ $ \s -> s { status = t }
     when (t == Stopped) playNext   -- transition to next song
 
-handleMsg (R f) = do
+handleMsg (F f) = do
     silentlyModifyHS \st -> st { clock = Just f }
-    getsHS clockUpdate >>= flip when UI.refreshClock
+    UI.refreshClock
 
 ------------------------------------------------------------------------
 --
@@ -316,15 +290,11 @@
 seekStart :: IO ()
 seekStart = seek $ const 0
 
-
 -- | Generic seek
 seek :: (Frame -> Int) -> IO ()
 seek fn = do
     mfr <- getsHS clock
-    whenJust mfr \fr -> do
-        sendMpg $ Jump (fn fr)
-        silentlyModifyHS $ \st -> st { clockUpdate = True }
-
+    whenJust mfr \fr -> sendMpg $ Jump $ fn fr
 
 ------------------------------------------------------------------------
 
@@ -352,7 +322,7 @@
 jump = jumpFn . const
 
 -- | Jump to relative place, 0 to 1.
-jumpRel :: Float -> IO ()
+jumpRel :: Rational -> IO ()
 jumpRel r | r < 0 || r >= 1 = pure ()
           | True = modifyHS_ $ \st ->
               st { cursor = floor $ fromIntegral (size st) * r }
@@ -436,7 +406,7 @@
                 { current = new
                 , status  = Playing
                 , cursor  = if current == cursor then new else cursor
-                , playHist = Seq.take 36 $ (now, new) Seq.<| playHist
+                , playHist = Seq.take histSize $ (now, new) <| playHist
                 , id3     = Nothing
                 }
             pure f
@@ -467,32 +437,27 @@
 
 -- | Generic jump to dir
 jumpToDir :: (Int -> Int -> Int) -> IO ()
-jumpToDir fn = modifyHS_ $ \st -> if size st == 0 then st else
+jumpToDir fn = modifyHS_ \st ->
     let i   = fdir (music st ! cursor st)
-        len = 1 + (snd . bounds $ folders st)
-        d   = fn i len
+        d   = fn i (length $ folders st)
     in st { cursor = dlo (folders st ! d) }
 
 ------------------------------------------------------------------------
 
---
 -- a bit of bounded parametric polymorphism so we can abstract over record selectors
 -- in the regex search stuff below
---
 class Lookup a       where extract :: a -> RawFilePath
-instance Lookup Tree.Dir  where extract = takeFileName . dname
-instance Lookup Tree.File where extract = fbase
+instance Lookup Dir  where extract = takeFileName . dname
+instance Lookup File where extract = fbase
 
 jumpToMatchFile :: Maybe String -> Bool -> IO ()
 jumpToMatchFile re sw = genericJumpToMatch re sw k sel
-    where k st = (music st, if size st == 0 then -1 else cursor st, size st)
+    where k st = (music st, cursor st, size st)
           sel i _ = i
 
 jumpToMatchDir :: Maybe String -> Bool -> IO ()
 jumpToMatchDir re sw = genericJumpToMatch re sw k sel
-    where k st = (folders st
-                     , if size st == 0 then -1 else fdir (music st ! cursor st)
-                     , 1 + (snd . bounds $ folders st))
+    where k st = (folders st, fdir (music st ! cursor st), length $ folders st)
           sel i st = dlo (folders st ! i)
 
 genericJumpToMatch :: Lookup a
@@ -501,25 +466,19 @@
                    -> (HState -> (Array Int a, Int, Int))
                    -> (Int -> HState -> Int)
                    -> IO ()
-
 genericJumpToMatch re sw k sel = do
-    found <- modifyHS $ \st -> do
-        let mre = case re of
-                Nothing -> case regex st of
-                    Nothing     -> Nothing
-                    Just (r, d) -> Just (r, d==sw)
-                Just s  -> case compileM (P.pack s) [caseless, utf8] of
-                    Left _      -> Nothing
-                    Right v     -> Just (v, sw)
-        flip (maybe (st, False)) mre \ (p, forwards) -> do
+    found <- modifyHS \st -> let
+        info = case re of
+            Just s -> Just (st { searchFw = sw }, s, sw)
+            _      -> listToMaybe [ (st, s, searchFw st == sw) | s <- searchHist st ]
+        in flip (maybe (st, False)) info \(st', p, forwards) -> do
             let (fs, cur, m) = k st
-                l = if forwards then [cur+1..m-1] ++ [0..cur]
-                                else [cur-1,cur-2..0] ++ [m-1,m-2..cur]
-                st' = st { regex = Just (p, forwards==sw) }
-            case [ i | i <- l, isJust $ match p (extract (fs ! i)) [] ] of
+                l = if forwards then [cur+1 .. m-1] ++ [0 .. cur]
+                                else [cur-1, cur-2 .. 0] ++ [m-1, m-2 .. cur]
+                match = matches (P.pack p)
+            case [ i | i <- l, match $ extract (fs ! i) ] of
                 i:_ -> (st' { cursor = sel i st }, True)
                 _   -> (st', False)
-
     unless found $ putMessage $ Fast "No match found." defaultSty
 
 ------------------------------------------------------------------------
diff --git a/Decoder.hs b/Decoder.hs
new file mode 100644
--- /dev/null
+++ b/Decoder.hs
@@ -0,0 +1,152 @@
+-- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
+-- Copyright (c) 2008, 2019-2026 Galen Huntington
+-- SPDX-License-Identifier: GPL-2.0-or-later
+
+-- Wire protocol for mpg123
+
+module Decoder (
+    mpgParser, Cmd(..), cmdToBS,
+    Msg(..), Id3(..), Status(..), Frame(..),
+) where
+
+import Base
+
+import Data.ByteString.Char8 qualified as P
+import Data.ByteString.UTF8 qualified as UTF8
+
+------------------------------------------------------------------------
+-- Send commands to mpg123
+
+data Cmd = Load ByteString | Jump Int | Pause | Quit
+
+cmdToBS :: Cmd -> ByteString
+cmdToBS (Load f) = "L " <> f
+cmdToBS (Jump i) = "J " <> P.pack (show i) -- can be relative with +/-; not used here
+cmdToBS Pause    = "P"  -- (un)pauses
+cmdToBS Quit     = "Q"
+
+------------------------------------------------------------------------
+-- Receive messages from mpg123
+
+data Msg = I                !Id3
+         | S {-# UNPACK #-} !ByteString
+         | F {-# UNPACK #-} !Frame
+         | P                !Status
+    deriving stock (Eq, Show)
+
+-- ID3 info
+data Id3 = Id3
+    { id3title  :: !ByteString
+    , id3artist :: !ByteString
+    , id3album  :: !ByteString
+    , id3str    :: !ByteString
+    --  , year   :: Maybe ByteString
+    --  , genre  :: Maybe ByteString }
+    } deriving stock (Eq, Show)
+
+-- Frame decoding status updates (once per frame).
+-- Current-frame and frames-remaining are integers; current-time and
+-- time-remaining floating point numbers with two decimal places.
+data Frame = Frame {
+    currentFrame   :: !Int,
+    framesLeft     :: !Int,
+    currentTime    :: !(Fixed E2),
+    timeLeft       :: !(Fixed E2)
+    } deriving stock (Eq, Show)
+
+-- Stop/pause status.
+data Status = Stopped | Paused | Playing
+    deriving stock (Eq, Show)
+
+-- | Strip leading and trailing whitespace.
+trim :: ByteString -> ByteString
+trim = P.dropWhileEnd isSpace . P.dropSpace
+
+readPS :: ByteString -> Maybe Int
+readPS = fmap fst . P.readInt
+
+doP :: ByteString -> Maybe Msg
+doP s = do
+    (p, _) <- P.uncons s
+    case p of
+        '0' -> pure $ P Stopped
+        '1' -> pure $ P Paused
+        '2' -> pure $ P Playing
+        _   -> Nothing -- don't need P 3 at end of song
+
+-- Frame decoding status updates (once per frame).
+doF :: ByteString -> Maybe Msg
+doF s = do
+    f0 : f1 : f2 : f3 : _ <- pure $ P.split ' ' s
+    currentFrame <- readPS f0
+    framesLeft   <- readPS f1
+    currentTime  <- readMaybe $ P.unpack f2
+    timeLeft     <- max 0 <$> readMaybe (P.unpack f3)
+    pure $ F Frame { currentFrame, framesLeft, currentTime, timeLeft }
+
+-- Info about mp3 file after loading.
+-- Breakdown from mpg123 README.remote (as numbers):
+--   0 = mpeg type (string)
+--   1 = layer (int)
+--   2 = sampling frequency (int)
+--   3 = mode (string)
+--   4 = mode extension (int)
+--   5 = framesize (int)
+--   6 = stereo (int)
+--   7 = copyright (int)
+--   8 = error protection (int)
+--   9 = emphasis (int)
+--  10 = bitrate (int)
+--  11 = extension (int)
+doS :: ByteString -> Maybe Msg
+doS s = do
+    let fs = P.split ' ' s
+    guard $ length fs >= 11
+    hz <- readPS $ fs !! 2
+    pure $ S $ mconcat [
+        "mpeg ", fs !! 0, " ", fs !! 10, "kb/s ",
+            P.pack $ show $ hz `div` 1000, "kHz"]
+
+-- Track info if ID fields are in the file, otherwise file name.
+doI :: ByteString -> Maybe Msg
+doI s = I <$> do
+    ("ID3:", info) <- pure $ P.splitAt 4 s
+    let id3 = parseId3 info
+    guard $ not $ P.null $ id3title id3 -- title sometimes empty
+    pure id3
+
+-- Format: title (30), author (30), album (30), year (4), comment (30), genre
+-- We currently only use the first three.
+parseId3 :: ByteString -> Id3
+parseId3 = toId . cut where
+    cut f | P.null f = []
+          | True     = let (a, xs) = P.splitAt 30 f in normalise a : cut xs
+    toId ls = Id3 (arg 0) (arg 1) (arg 2) $ mconcat $ intersperse " : "
+        $ filter (not . P.null) [arg 1, arg 2, arg 0]
+      where arg = fromMaybe "" . (ls !?)
+
+-- | Strip spaces, and if seeming ISO-8859-1 convert to UTF-8
+normalise :: ByteString -> ByteString
+normalise raw =
+    let bs = trim raw
+    in if UTF8.replacement_char `elem` UTF8.toString bs
+        then UTF8.fromString $ P.unpack bs
+        else bs
+
+-- Parse line; on failure, return Just only if error to report.
+mpgParser :: ByteString -> Either (Maybe String) Msg
+mpgParser line = do
+    -- bad packets are generally just \n in ID3 (and not of interest anyway)
+    let quiet = maybe (Left Nothing) pure
+    code <- quiet do
+        '@' : c : ' ' : _ <- pure $ P.unpack line
+        pure c
+    let m = P.drop 3 line
+    case code of
+        'I' -> quiet $ doI m
+        'S' -> quiet $ doS m
+        'F' -> quiet $ doF m
+        'P' -> quiet $ doP m
+        'E' -> Left $ Just $ P.unpack m
+        _   -> quiet Nothing
+
diff --git a/Keyboard.hs b/Keyboard.hs
--- a/Keyboard.hs
+++ b/Keyboard.hs
@@ -3,11 +3,12 @@
 -- Copyright (c) 2019, 2023-2026 Galen Huntington
 -- SPDX-License-Identifier: GPL-2.0-or-later
 
-module Keyboard (unkey, charToKey, Key(..)) where
+module Keyboard (unkey, charToKey, Key(..), historyKeys) where
 
 import Base
 
-import qualified Data.Map.Strict as M
+import Data.Map.Strict qualified as M
+import Data.Sequence qualified as Seq
 import UI.HSCurses.Curses (Key(..), decodeKey)
 
 ------------------------------------------------------------------------
@@ -28,4 +29,7 @@
 
 unkey :: Key -> Char
 unkey k = fromMaybe '\0' $ M.lookup k keyCharMap
+
+historyKeys :: Seq Char
+historyKeys = Seq.fromList $ ['0'..'9'] ++ ['a'..'z'] ++ filter (/='H') ['A'..'Z']
 
diff --git a/Keymap.hs b/Keymap.hs
--- a/Keymap.hs
+++ b/Keymap.hs
@@ -16,14 +16,14 @@
 
 import Core
 import Config (package)
-import Keyboard (unkey, charToKey, Key(..))
+import Keyboard (unkey, charToKey, Key(..), historyKeys)
 import State (getsHS, modifyHS_, KeysHelp, Modal(..), HState(..))
 import Style (defaultSty, StringA(Fast))
-import qualified UI (getKey, resetui)
+import UI qualified (getKey, resetui)
 
-import qualified Data.ByteString.Char8 as P
-import qualified Data.ByteString.UTF8 as UTF8
-import qualified Data.Map.Strict as M
+import Data.ByteString.Char8 qualified as P
+import Data.ByteString.UTF8 qualified as UTF8
+import Data.Map.Strict qualified as M
 
 
 ------------------------------------------------------------------------
@@ -47,9 +47,9 @@
 mainMode :: KeyMap
 mainMode = KeyMap \c -> getsHS modal >>= \case
 
-    Just ExitModal -> case c of
-        'y' -> shutdown Nothing $> undefined -- shutdown never returns
-        _   -> closeModal $> mainMode
+    Just ExitModal
+        | c `elem` ['y', 'Y', '\^C'] -> shutdown Nothing $> undefined
+        | True                       -> closeModal $> mainMode
 
     Just (HistModal hist) -> do
         for_ (M.lookup c historyKeyMap >>= (hist !?)) (jump . fst . snd)
@@ -60,17 +60,17 @@
             toggleFocus
             hist <- getsHS searchHist
             searchMode c $ Zipper "" hist []
-        | c == 'q' ->
+        | c `elem` ['q', '\^C'] ->
             forcePause *> setsModal (const $ Just ExitModal) $> mainMode
         | c `elem` ['H', ';'] ->
             showHist $> mainMode
         | c >= '1' && c <= '9' ->
-            jumpRel (0.1 * fromIntegral (fromEnum c - 48)) $> mainMode
+            jumpRel (fromIntegral (fromEnum c - 48) / 10) $> mainMode
         | True -> sequence_ (M.lookup c keyMap) $> mainMode
 
 
 historyKeyMap :: M.Map Char Int
-historyKeyMap = M.fromList $ zip (['0'..'9'] ++ ['a'..'z']) [0..]
+historyKeyMap = M.fromList $ zip (toList historyKeys) [0..]
 
 
 ------------------------------------------------------------------------
@@ -86,13 +86,15 @@
     step z = renderSearch stype z $> KeyMap (`dispatch` z)
 
     dispatch c z
-        | c == '\ESC'      = clearMessage *> leave
+        | c `elem` ['\ESC', '\^C']
+                           = clearMessage *> leave
         | c `elem` enter'  = commit z
         | c `elem` delete' = step $ zipEdit dropLast z
         | k == KeyUp       = step $ zipUp z
         | k == KeyDown     = step $ zipDown z
         | k == KeyDC       = histDelete z
-        | c > '\255'       = step z         -- ignore other special keys
+        | c < ' ' || c > '\255'
+                           = step z   -- ignore other special keys
         | otherwise        = step $ zipEdit (++ [c]) z
       where k = charToKey c
 
diff --git a/Lexer.hs b/Lexer.hs
deleted file mode 100644
--- a/Lexer.hs
+++ /dev/null
@@ -1,114 +0,0 @@
--- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
--- Copyright (c) 2008, 2019-2026 Galen Huntington
--- SPDX-License-Identifier: GPL-2.0-or-later
-
--- Lexer for mpg123 messages
-
-module Lexer ( mpgParser ) where
-
-import Base
-import Syntax (Msg(..), Status(..), Frame(..), Info(..), Id3(..), Tag(..))
-
-import qualified Data.ByteString.Char8 as P
-import qualified Data.ByteString.UTF8 as UTF8
-
-------------------------------------------------------------------------
-
--- | Strip leading and trailing whitespace.
-trim :: ByteString -> ByteString
-trim = P.dropWhileEnd isSpace . P.dropSpace
-
-readPS :: ByteString -> Maybe Int
-readPS = fmap fst . P.readInt
-
-doP :: ByteString -> Maybe Msg
-doP s = do
-    (p, _) <- P.uncons s
-    case p of
-        '0' -> pure $ S Stopped
-        '1' -> pure $ S Paused
-        '2' -> pure $ S Playing
-        -- recent mpg123 outputs 3 for end of song; don't need
-        _   -> Nothing
-
--- Frame decoding status updates (once per frame).
-doF :: ByteString -> Maybe Msg
-doF s = do
-    f0 : f1 : f2 : f3 : _ <- pure $ P.split ' ' s
-    currentFrame <- readPS f0
-    framesLeft   <- readPS f1
-    currentTime  <- readMaybe $ P.unpack f2
-    timeLeft     <- max 0 <$> readMaybe (P.unpack f3)
-    pure $ R Frame { currentFrame , framesLeft, currentTime, timeLeft }
-
--- Info about mp3 file after loading.
--- Breakdown from mpg123 README.remote (as numbers):
---   0 = mpeg type (string)
---   1 = layer (int)
---   2 = sampling frequency (int)
---   3 = mode (string)
---   4 = mode extension (int)
---   5 = framesize (int)
---   6 = stereo (int)
---   7 = copyright (int)
---   8 = error protection (int)
---   9 = emphasis (int)
---  10 = bitrate (int)
---  11 = extension (int)
-doS :: ByteString -> Maybe Msg
-doS s = do
-    let fs = P.split ' ' s
-    guard $ length fs >= 11
-    hz <- readPS $ fs !! 2
-    pure $ I $ Info $ mconcat [
-        "mpeg ", fs !! 0, " ", fs !! 10, "kbit/s ",
-            P.pack $ show $ hz `div` 1000, "kHz"]
-
--- Track info if ID fields are in the file, otherwise file name.
-doI :: ByteString -> Maybe Msg
-doI s = F <$> do
-    ("ID3:", info) <- pure $ P.splitAt 4 s
-    let id3 = parseId3 info
-    guard $ not $ P.null $ id3title id3 -- title sometimes empty
-    pure id3
-
--- Format: title (30), author (30), album (30), year (4), comment (30), genre
--- We currently only use the first three.
-parseId3 :: ByteString -> Id3
-parseId3 = toId . cut where
-    cut f | P.null f = []
-          | True     = let (a, xs) = P.splitAt 30 f in normalise a : cut xs
-    toId ls = Id3 (arg 0) (arg 1) (arg 2) $ mconcat $ intersperse " : "
-        $ filter (not . P.null) [arg 1, arg 2, arg 0]
-      where arg = fromMaybe "" . (ls !?)
-
--- | Strip spaces, and if seeming ISO-8859-1 convert to UTF-8
-normalise :: ByteString -> ByteString
-normalise raw =
-    let bs = trim raw
-    in if UTF8.replacement_char `elem` UTF8.toString bs
-        then UTF8.fromString $ P.unpack bs
-        else bs
-
-------------------------------------------------------------------------
-
--- Parse line; on failure, return Just only if error to report.
-mpgParser :: ByteString -> Either (Maybe String) Msg
-mpgParser line = do
-    -- bad packets are generally just \n in ID3 (and not of interest anyway)
-    let quiet = maybe (Left Nothing) pure
-
-    code <- quiet do
-        '@' : c : ' ' : _ <- pure $ P.unpack line
-        pure c
-
-    let m = P.drop 3 line
-    case code of
-        'R' -> pure $ T Tag
-        'I' -> quiet $ doI m
-        'S' -> quiet $ doS m
-        'F' -> quiet $ doF m
-        'P' -> quiet $ doP m
-        'E' -> Left $ Just $ P.unpack m
-        _   -> quiet Nothing
-
diff --git a/Playlist.hs b/Playlist.hs
new file mode 100644
--- /dev/null
+++ b/Playlist.hs
@@ -0,0 +1,131 @@
+-- Copyright (c) 2005-8 Don Stewart - http://www.cse.unsw.edu.au/~dons
+-- Copyright (c) 2019-2020, 2025-2026 Galen Huntington
+-- SPDX-License-Identifier: GPL-2.0-or-later
+
+module Playlist (module Playlist, RawFilePath) where
+
+import Base
+
+import Data.ByteString.Char8 qualified as P
+import Data.Map.Strict qualified as M
+
+import Data.Array
+import System.Posix.FilePath
+import System.Posix.Files.ByteString (getFileStatus, isDirectory, fileAccess)
+import System.Posix.Directory.Traversals (getDirectoryContents)
+
+
+-- | A filesystem hierarchy is flattened to just the end nodes
+type DirArray = Array Int Dir
+
+-- | The complete list of .mp3 files
+type FileArray = Array Int File
+
+-- | A directory entry is the directory name, and a list of bound
+-- indicies into the Files array.
+data Dir  =
+    Dir { dname :: !RawFilePath        -- ^ directory name
+        , dsize :: !Int              -- ^ number of file entries
+        , dlo   :: !Int              -- ^ index of first entry
+        , dhi   :: !Int }            -- ^ index of last entry
+
+-- Most data is allocated in this structure
+data File =
+    File { fbase :: !RawFilePath      -- ^ basename of file
+         , fdir  :: !Int }          -- ^ index of Dir entry 
+
+data Playlist = Playlist !DirArray !FileArray
+
+--
+-- | Given the start directories, populate the dirs and files arrays
+--
+buildPlaylist :: [RawFilePath] -> IO Playlist
+buildPlaylist fs = do
+    -- note we will lose the ordering of files given on cmd line.
+    (os, dirs) <- catch @SomeException (sift fs)
+        \e -> print e *> exitWith (ExitFailure 1)
+
+    let loop []     = pure []
+        loop (a:xs) = do
+            (m, ds) <- expandDir a
+            ms      <- loop $ ds ++ xs  -- add to work list
+            pure $ m : ms
+
+    ms' <- catMaybes <$> loop dirs
+
+    let extras = merge . doOrphans $ os
+        ms = ms' ++ extras
+
+    let (_,n,dirls,filels) = foldl' make (0,0,[],[]) ms
+        dirsArray = listArray (0,length dirls - 1) (reverse dirls)
+        fileArray = listArray (0, n-1) (reverse filels)
+
+    pure $! Playlist dirsArray fileArray
+
+-- | Is the playlist empty?
+isEmpty :: Playlist -> Bool
+isEmpty (Playlist _ files) = null files
+
+-- | Create nodes based on dirname for orphan files on cmdline
+doOrphans :: [RawFilePath] -> [(RawFilePath, [RawFilePath])]
+doOrphans = map \f -> (takeDirectory f, [takeFileName f])
+
+-- | Merge entries with the same root node into a single node
+merge :: [(RawFilePath, [RawFilePath])] -> [(RawFilePath, [RawFilePath])]
+merge = M.assocs . M.fromListWith (flip (++))
+
+-- | fold builder, for generating Dirs and Files
+make :: (Int,Int,[Dir],[File]) -> (RawFilePath,[RawFilePath]) -> (Int,Int,[Dir],[File])
+make (i,n,acc1,acc2) (d,fs) =
+    let (dir, n') = listToDir n d fs
+        fs'= map makeFile fs
+    in (i+1, n', dir:acc1, reverse fs' ++ acc2)
+  where
+    makeFile f = File (takeFileName f) i
+
+------------------------------------------------------------------------
+
+-- | Expand a single directory into a maybe a pair of the dir name and any files
+-- Return any extra directories to search in
+--
+-- Assumes no evil sym links
+--
+expandDir :: RawFilePath -> IO (Maybe (RawFilePath, [RawFilePath]),  [RawFilePath])
+expandDir !f = do
+    ls <- map (f </>) . sort . filter notHidden . map snd
+        <$> getDirectoryContents f
+    (fs', ds) <- sift ls
+    let fs = filter isMp3 fs'
+        v = guard (not $ null fs) *> Just (f, fs)
+    pure (v, ds)
+  where
+    notHidden = not . P.isPrefixOf "."
+    isMp3     = (== ".mp3") . P.map toLower . takeExtension
+
+-- | Given an index into the files array, a directory name, and
+-- a list of files in that dir, build a Dir and return the next index
+-- into the array
+listToDir :: Int -> RawFilePath -> [RawFilePath] -> (Dir, Int)
+listToDir n d fs = (dir, n') where
+    dir = Dir
+        { dname = dropTrailingPathSeparator d
+        , dsize = len
+        , dlo   = n
+        , dhi   = n + len - 1
+        }
+    len = length fs
+    n'  = n + len
+
+-- | Break a pair of sublists of files and directories, filtering
+-- out ones without permission.
+sift :: [RawFilePath] -> IO ([RawFilePath], [RawFilePath])
+sift []     = pure ([], [])
+sift (p:ps) = do
+    it@(fs,ds) <- sift ps
+    isDir <- isDirectory <$> getFileStatus p
+    perm <- fileAccess p True False isDir
+    pure if
+        | not perm -> it
+        | isDir    -> (fs, p:ds)
+        | True     -> (p:fs, ds)
+
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@
 regeneration of a `configure` file (now gone).
 
 *  The code has been updated to compile under recent GHC (tested
-through 9.14; minimum supported 9.0) and libraries.  This required
+through 9.14; minimum supported 9.2) and libraries.  This required
 rewriting or entirely replacing large sections, mainly low-level
 optimizations.
 
@@ -59,10 +59,7 @@
 
 Either `cabal install` or `stack install` will build a binary.
 You will need to have `mpg123` installed, which is free software and
-widely available in package managers.  Alternatively, `mpg321` can
-be used by compiling with the `-DMPG321` option.  In my experience,
-the latter worked better, but it too is abandoned, with no update
-since 2012, and is no longer available on many systems.
+widely available in package managers.
 
 The build depends on the package `hscurses`, which in turn requires
 curses dev files.  In Ubuntu/Debian, for example, these can be obtained
diff --git a/State.hs b/State.hs
--- a/State.hs
+++ b/State.hs
@@ -9,17 +9,15 @@
 
 import Base
 
-import Syntax                   (Status, Mode, Frame, Info, Id3, Pretty(ppr))
-import Tree                     (FileArray, DirArray)
+import Decoder                  (Status, Frame, Id3, Cmd, cmdToBS)
+import Playlist                 (FileArray, DirArray)
 import Style                    (StringA, UIStyle)
 
 import Data.ByteString          (hPut)
-import Data.Sequence            (Seq)
 import System.Clock             (TimeSpec(..))
 import System.IO                (hFlush)
 import System.Process           (ProcessHandle)
 import System.Random            (StdGen)
-import Text.Regex.PCRE.Light    (Regex)
 
 
 -- | Player state
@@ -34,26 +32,29 @@
     , current         :: !Int                  -- currently playing mp3
     , cursor          :: !Int                  -- mp3 under the cursor
     , clock           :: !(Maybe Frame)        -- current clock value
-    , clockUpdate     :: !Bool
     , randomGen       :: !StdGen               -- random seed
     , mpgPid          :: !(Maybe ProcessHandle) -- pid of decoder
     , spawns          :: !Integer              -- count of decoder spawns
     , threads         :: ![ThreadId]           -- all our threads
     , id3             :: !(Maybe Id3)          -- maybe mp3 id3 info
-    , info            :: !(Maybe Info)         -- mp3 info
+    , info            :: !(Maybe ByteString)   -- mp3 info
     , status          :: !Status
     , minibuffer      :: !StringA              -- contents of minibuffer
     , modal           :: !(Maybe Modal)        -- modal visible
     , miniFocused     :: !Bool                 -- is the mini buffer focused?
     , mode            :: !Mode
     , uptime          :: !ByteString
-    , regex           :: !(Maybe (Regex,Bool)) -- most recent search pattern and direction
+    , searchFw        :: !Bool                 -- active search direction
     , searchHist      :: ![String]
     , exiting         :: !Bool                 -- let mpg123 die?
     , playHist        :: !(Seq (TimeSpec, Int))
+    , histSize        :: Int
     , config          :: !UIStyle
     }
 
+data Mode = Once | Loop | Random | Single
+    deriving stock (Eq, Bounded, Enum, Show, Read)
+
 -- Each is (timestamp-string, (song-index, song-name)).
 type HistDisplay = [(ByteString, (Int, ByteString))]
 
@@ -79,19 +80,15 @@
 ------------------------------------------------------------------------
 -- The decoder.
 
-data Mpg = Mpg
-    { writeh :: !Handle
-    , readh  :: !Handle
-    , errh   :: !Handle
-    }
+data Mpg = Mpg { errh :: !Handle, writeh :: !Handle }
 
 mpg :: MVar Mpg
 mpg = unsafePerformIO newEmptyMVar
 {-# NOINLINE mpg #-}
 
-sendMpg :: Pretty a => a -> IO ()
-sendMpg s = withMVar mpg $ (. writeh) \h ->
-    hPut h (ppr s) >> hPut h "\n" >> hFlush h
+sendMpg :: Cmd -> IO ()
+sendMpg c = withMVar mpg $ (. writeh) \h ->
+    hPut h (cmdToBS c) *> hPut h "\n" *> hFlush h
 
 ------------------------------------------------------------------------
 -- state accessor functions
diff --git a/Style.hs b/Style.hs
--- a/Style.hs
+++ b/Style.hs
@@ -9,8 +9,8 @@
 module Style where
 
 import Base
-import qualified UI.HSCurses.Curses as Curses
-import qualified Data.Map as M
+import UI.HSCurses.Curses qualified as Curses
+import Data.Map qualified as M
 
 ------------------------------------------------------------------------
 
diff --git a/Syntax.hs b/Syntax.hs
deleted file mode 100644
--- a/Syntax.hs
+++ /dev/null
@@ -1,107 +0,0 @@
--- Copyright (c) 2005-2008 Don Stewart - http://www.cse.unsw.edu.au/~dons
--- Copyright (c) 2019-2026 Galen Huntington
--- SPDX-License-Identifier: GPL-2.0-or-later
-
---
--- abstract syntax for mpg123/321 'remote control' commands, so we get
--- type safe messaging, and parsing of results
---
-
-module Syntax where
-
-import Base
-
-import qualified Data.ByteString.Char8 as P
-
-------------------------------------------------------------------------
---
--- Values we may print out:
-
--- Loads and starts playing <file>
---
-newtype Load = Load ByteString
-
-instance Pretty Load where
-    ppr (Load f) = mconcat ["LOAD ", f]
-
--- If '+' or '-' is specified, jumps <frames> frames forward, or backwards,
--- respectively, in the the mp3 file.  If neither is specifies, jumps to
--- absolute frame <frames> in the mp3 file.
-newtype Jump = Jump Int
-
-instance Pretty Jump where
-    ppr (Jump i) = mconcat ["JUMP ", P.pack . show $ i]
-
--- Pauses the playback of the mp3 file; if already paused, restarts playback.
-data Pause = Pause
-
-instance Pretty Pause where
-    ppr Pause = "PAUSE"
-
--- Quits mpg123.
-data Quit = Quit
-
-instance Pretty Quit where
-    ppr Quit = "QUIT"
-
-------------------------------------------------------------------------
---
--- Values we may have to read back in
-
--- mpg123 tagline. Output at startup.
-data Tag = Tag
-    deriving stock (Eq, Show)
-
--- ID3 info
-data Id3 = Id3
-        { id3title  :: !ByteString
-        , id3artist :: !ByteString
-        , id3album  :: !ByteString
-        , id3str    :: !ByteString
-        }
-    deriving stock (Eq, Show)
-
---      , year   :: Maybe ByteString
---      , genre  :: Maybe ByteString }
-
-
--- mp3 file info; TODO maybe don't need this newtype at all?
-newtype Info = Info { userinfo :: ByteString }
-    deriving stock (Eq, Show)
-
--- Frame decoding status updates (once per frame).
--- Current-frame and frames-remaining are integers; current-time and
--- time-remaining floating point numbers with two decimal places.
-data Frame = Frame {
-                currentFrame   :: !Int,
-                framesLeft     :: !Int,
-                currentTime    :: !(Fixed E2),
-                timeLeft       :: !(Fixed E2)
-             }
-    deriving stock (Eq, Show)
-
--- Stop/pause status.
-data Status = Stopped | Paused | Playing
-    deriving stock (Eq, Show)
-
-data Mode = Once | Loop | Random | Single
-    deriving stock (Eq, Bounded, Enum, Show, Read)
-
-------------------------------------------------------------------------
-
---
--- a pretty printing class
---
-class Pretty a where
-    ppr :: a -> ByteString
-
---
--- And a wrapper type 
---
-data Msg = T {-# UNPACK #-} !Tag
-         | F                !Id3
-         | I {-# UNPACK #-} !Info
-         | R {-# UNPACK #-} !Frame
-         | S                !Status
-    deriving stock (Eq, Show)
-
diff --git a/Tree.hs b/Tree.hs
deleted file mode 100644
--- a/Tree.hs
+++ /dev/null
@@ -1,135 +0,0 @@
--- Copyright (c) 2005-8 Don Stewart - http://www.cse.unsw.edu.au/~dons
--- Copyright (c) 2019-2020, 2025-2026 Galen Huntington
--- SPDX-License-Identifier: GPL-2.0-or-later
-
---
--- functions for manipulating file trees
---
-
-module Tree (module Tree, RawFilePath) where
-
-import Base
-
-import qualified Data.ByteString.Char8 as P
-import qualified Data.Map.Strict as M
-
-import Data.Array
-import System.Posix.FilePath
-import System.Posix.Files.ByteString (getFileStatus, isDirectory, fileAccess)
-import System.Posix.Directory.Traversals (getDirectoryContents)
-
-
--- | A filesystem hierarchy is flattened to just the end nodes
-type DirArray = Array Int Dir
-
--- | The complete list of .mp3 files
-type FileArray = Array Int File
-
--- | A directory entry is the directory name, and a list of bound
--- indicies into the Files array.
-data Dir  =
-    Dir { dname :: !RawFilePath        -- ^ directory name
-        , dsize :: !Int              -- ^ number of file entries
-        , dlo   :: !Int              -- ^ index of first entry
-        , dhi   :: !Int }            -- ^ index of last entry
-
--- Most data is allocated in this structure
-data File =
-    File { fbase :: !RawFilePath      -- ^ basename of file
-         , fdir  :: !Int }          -- ^ index of Dir entry 
-
-data Tree = Tree !DirArray !FileArray
-
---
--- | Given the start directories, populate the dirs and files arrays
---
-buildTree :: [RawFilePath] -> IO Tree
-buildTree fs = do
-    -- note we will lose the ordering of files given on cmd line.
-    (os, dirs) <- catch @SomeException (sift fs)
-        \e -> print e *> exitWith (ExitFailure 1)
-
-    let loop []     = pure []
-        loop (a:xs) = do
-            (m, ds) <- expandDir a
-            ms      <- loop $ ds ++ xs  -- add to work list
-            pure $ m : ms
-
-    ms' <- catMaybes <$> loop dirs
-
-    let extras = merge . doOrphans $ os
-        ms = ms' ++ extras
-
-    let (_,n,dirls,filels) = foldl' make (0,0,[],[]) ms
-        dirsArray = listArray (0,length dirls - 1) (reverse dirls)
-        fileArray = listArray (0, n-1) (reverse filels)
-
-    pure $! Tree dirsArray fileArray
-
--- | Is the tree empty?
-isEmpty :: Tree -> Bool
-isEmpty (Tree _ files) = null files
-
--- | Create nodes based on dirname for orphan files on cmdline
-doOrphans :: [RawFilePath] -> [(RawFilePath, [RawFilePath])]
-doOrphans = map \f -> (takeDirectory f, [takeFileName f])
-
--- | Merge entries with the same root node into a single node
-merge :: [(RawFilePath, [RawFilePath])] -> [(RawFilePath, [RawFilePath])]
-merge = M.assocs . M.fromListWith (flip (++))
-
--- | fold builder, for generating Dirs and Files
-make :: (Int,Int,[Dir],[File]) -> (RawFilePath,[RawFilePath]) -> (Int,Int,[Dir],[File])
-make (i,n,acc1,acc2) (d,fs) =
-    let (dir, n') = listToDir n d fs
-        fs'= map makeFile fs
-    in (i+1, n', dir:acc1, reverse fs' ++ acc2)
-  where
-    makeFile f = File (takeFileName f) i
-
-------------------------------------------------------------------------
-
--- | Expand a single directory into a maybe a pair of the dir name and any files
--- Return any extra directories to search in
---
--- Assumes no evil sym links
---
-expandDir :: RawFilePath -> IO (Maybe (RawFilePath, [RawFilePath]),  [RawFilePath])
-expandDir !f = do
-    ls <- map (f </>) . sort . filter notHidden . map snd
-        <$> getDirectoryContents f
-    (fs', ds) <- sift ls
-    let fs = filter isMp3 fs'
-        v = guard (not $ null fs) *> Just (f, fs)
-    pure (v, ds)
-  where
-    notHidden = not . P.isPrefixOf "."
-    isMp3     = (== ".mp3") . P.map toLower . takeExtension
-
--- | Given an index into the files array, a directory name, and
--- a list of files in that dir, build a Dir and return the next index
--- into the array
-listToDir :: Int -> RawFilePath -> [RawFilePath] -> (Dir, Int)
-listToDir n d fs = (dir, n') where
-    dir = Dir
-        { dname = dropTrailingPathSeparator d
-        , dsize = len
-        , dlo   = n
-        , dhi   = n + len - 1
-        }
-    len = length fs
-    n'  = n + len
-
--- | Break a pair of sublists of files and directories, filtering
--- out ones without permission.
-sift :: [RawFilePath] -> IO ([RawFilePath], [RawFilePath])
-sift []     = pure ([], [])
-sift (p:ps) = do
-    it@(fs,ds) <- sift ps
-    isDir <- isDirectory <$> getFileStatus p
-    perm <- fileAccess p True False isDir
-    pure if
-        | not perm -> it
-        | isDir    -> (fs, p:ds)
-        | True     -> (p:fs, ds)
-
diff --git a/UI.hs b/UI.hs
--- a/UI.hs
+++ b/UI.hs
@@ -21,13 +21,13 @@
 import Base
 
 import Style
-import Tree                     (File(fdir, fbase), Dir(dname))
+import Playlist                 (File(fdir, fbase), Dir(dname))
 import State
-import Syntax
+import Decoder
 import Config
 import Width                    (displayWidth, toMaxWidth, toWidth)
-import qualified UI.HSCurses.Curses as Curses
-import Keyboard                 (unkey, charToKey)
+import UI.HSCurses.Curses qualified as Curses
+import Keyboard                 (unkey, charToKey, historyKeys)
 
 import Data.Array               ((!), bounds, Array)
 import Data.Array.Base          (unsafeAt)
@@ -39,9 +39,9 @@
 import Foreign.C.Types
 import Foreign.C.Error (Errno(..), getErrno)
 
-import qualified Data.ByteString.Char8 as P
-import qualified Data.ByteString.Unsafe as P
-import qualified Data.ByteString.UTF8 as UTF8
+import Data.ByteString.Char8 qualified as P
+import Data.ByteString.Unsafe qualified as P
+import Data.ByteString.UTF8 qualified as UTF8
 
 
 -- Write u-strings like it's Python 2.
@@ -170,7 +170,7 @@
 
 ------------------------------------------------------------------------
 
--- | The three lines of the play-mode widget.
+-- | The three lines of the play info widget.
 playScreen :: DrawData -> [StringA]
 playScreen dd = [pPlaying dd, progressBar dd, pTimes dd]
 
@@ -178,7 +178,7 @@
 
 -- | Info about the current track
 pPlaying :: DrawData -> StringA
-pPlaying dd = FancyS $ map (, defaultSty) $ "  " : line where
+pPlaying dd = flip Fast defaultSty $ "  " <> mconcat line where
     x = sizeW $ drawSize dd
     a = pId3 dd
     b = pInfo dd
@@ -193,15 +193,11 @@
 pId3 :: DrawData -> ByteString
 pId3 DD{drawState=st} = case id3 st of
     Just i  -> id3str i
-    Nothing -> case size st of
-        0 -> "(empty)"
-        _ -> fbase $ music st ! current st
+    Nothing -> fbase $ music st ! current st
 
 -- | mp3 information
 pInfo :: DrawData -> ByteString
-pInfo DD{drawState=st} = case info st of
-    Nothing -> "(empty)"
-    Just i  -> userinfo i
+pInfo DD{drawState=st} = fromMaybe "" $ info st
 
 commonModalWidth :: Int -> Int
 commonModalWidth w = max (min w 3) $ round $ fromIntegral w * (0.8::Float)
@@ -235,13 +231,15 @@
 ------------------------------------------------------------------------
 
 histModal :: HistDisplay -> ModalMaker
+histModal []   _   = let s = "  No history  " in (P.length s, [s])
 histModal hist swd = do
     let wd = commonModalWidth swd
-        mtlen = maximum $ 0 : map (displayWidth . fst) hist
+        mtlen = maximum $ map (displayWidth . fst) hist
         tlen = min (mtlen + 1) $ wd `div` 3
-    (wd,) $ flip map (zip (['0'..'9']++['a'..'z']) hist) \ (c, (time, (_, song))) ->
+    (wd, [
         let tstr = toMaxWidth tlen $ P.replicate (tlen - displayWidth time) ' ' <> time
         in mconcat [" ", P.singleton c, " ", tstr, " ", song]
+        | (c, (time, (_, song))) <- zip (toList historyKeys ++ repeat ' ') hist ])
 
 ------------------------------------------------------------------------
 
@@ -252,24 +250,26 @@
 
 ------------------------------------------------------------------------
 
+showClock :: Fixed E2 -> ByteString
+showClock t =
+    let m, si, sd :: Int
+        (m, s) = t `divMod'` 60
+        si     = floor s
+        sd     = floor (s*10) `mod` 10
+    in P.pack $ printf "%d:%02d.%d" m si sd
+
 -- | The time used and time left
 pTimes :: DrawData -> StringA
-pTimes DD { drawFrame=Just Frame {..}, drawSize=Size{sizeW=x} } =
-    FancyS $ map (, defaultSty)
-        if x - 4 < P.length elapsed
-        then [" "]
-        else ["  ", elapsed]
-                ++ (guard (distance > 0) *> [gap, remaining])
+pTimes DD { drawFrame=Just Frame {..}, drawSize=Size{sizeW=w} } =
+    flip Fast defaultSty $ if w - 4 < P.length elapsed
+        then ""
+        else mconcat $ ["  ", elapsed] ++ [gap <> "-" <> remaining | distance > 0]
   where
-    elapsed   = P.pack $ printf "%d:%02d" l_m l_s
-    remaining = P.pack $ printf "-%d:%02d" r_m r_s
-    (l_m, l_s) = toMS currentTime
-    (r_m, r_s) = toMS timeLeft
-    gap        = spaces distance
-    distance   = x - 4 - P.length elapsed - P.length remaining
-    toMS :: RealFrac a => a -> (Int, Int)
-    toMS = flip quotRem 60 . floor
-pTimes _ = Fast (spaces 5) defaultSty
+    elapsed   = showClock currentTime
+    remaining = showClock timeLeft
+    gap       = spaces distance
+    distance  = w - 5 - P.length elapsed - P.length remaining
+pTimes _ = Fast "" defaultSty
 
 ------------------------------------------------------------------------
 
@@ -304,74 +304,59 @@
 pTime :: DrawData -> ByteString
 pTime = uptime . drawState
 
--- | Play mode
-pMode :: DrawData -> String
-pMode dd = case status (drawState dd) of
+-- | Play state
+pState :: DrawData -> String
+pState dd = case status (drawState dd) of
     Stopped -> "◼"
     Paused  -> "⏸"
     Playing -> "▶"
 
--- | Loop, normal, or random
-pMode2 :: DrawData -> String
-pMode2 dd = case mode (drawState dd) of
-    Random -> "rand"
-    Loop   -> "loop"
-    Once   -> "once"
-    Single -> "sing"
+-- | Play mode
+pMode :: DrawData -> String
+pMode dd = take 4 $ map toLower $ show $ mode $ drawState dd
 
 ------------------------------------------------------------------------
 
--- | The two play-mode glyphs (e.g. "▶ rand") rendered together.
-playModes :: DrawData -> String
-playModes dd = pMode dd ++ ' ' : pMode2 dd
-
--- | "x/n dir(s)  y/m file(s)" cursor position read-out.
+-- | "x/n dirs y/m files" cursor position read-out.
 playInfo :: DrawData -> ByteString
 playInfo dd = mconcat
-    -- TODO pregenerate as template
     [ spaces (P.length numd - P.length curd)
-    , curd, "/", numd, " dir", onPlural (snd . bounds $ folders st) "" "s"
-    , " "
-    , spaces (P.length numf - P.length curf)
-    , curf, "/", numf, " file", onPlural (size st) "" "s"
+    , curd, "/", numd, " dirs"
+    , spaces (1 + P.length numf - P.length curf)
+    , curf, "/", numf, " files"
     ]
   where
     st   = drawState dd
     tobs = P.pack . show
-    onPlural 1 s _ = s
-    onPlural _ _ p = p
     curf  = tobs $ 1 + cursor st
     numf  = tobs $ size st
     mydir = fdir $ music st ! cursor st
     curd  = tobs $ 1 + mydir
-    numd  = tobs $ 1 + snd (bounds $ folders st)
+    numd  = tobs $ length $ folders st
 
--- | The top title bar: cursor position + play modes + uptime + version.
+-- | The top title bar: cursor position + play indicator + uptime + version.
 playTitle :: DrawData -> StringA
 playTitle dd =
-    FancyS $ map (, hl)
-        if gap >= 2
-        then [mconcat [" ", inf, spaces gapl], modesBS,
-                mconcat [spaces gapr, time, " ", ver, " "]]
-        else let gap' = x - modlen; gapl' = gap' `div` 2
+    flip Fast hl $ mconcat if gap >= 2
+        then [" ", inf, spaces gapl, indic, spaces gapr, time, " ", ver, " "]
+        else let gap' = x - indicl; gapl' = gap' `div` 2
              in if gap' >= 2
-                then [spaces gapl', modesBS, spaces $ gap' - gapl']
-                else [" ", u $ take (x-2) modes, " "]
+                then [spaces gapl', indic, spaces $ gap' - gapl']
+                else [" ", P.take (x-2) indic, " "]
   where
     inf     = playInfo dd
     time    = pTime dd
-    modes   = playModes dd
+    indic   = u $ pState dd ++ ' ' : pMode dd
     ver     = pVersion
-    modesBS = u modes
 
     x       = sizeW $ drawSize dd
     lsize   = 1 + P.length inf
     rsize   = 2 + P.length time + P.length ver
-    side    = (x - modlen) `div` 2
-    gap     = x - modlen - lsize - rsize
+    side    = (x - indicl) `div` 2
+    gap     = x - indicl - lsize - rsize
     gapl    = 1 `max` ((side - lsize) `min` (gap - 1))
     gapr    = gap - gapl
-    modlen  = 6 -- length modes
+    indicl  = 6 -- length indic
     hl      = titlebar . config $ drawState dd
 
 -- | The scrolling playlist (title + visible tracks + minibuffer).
@@ -413,35 +398,29 @@
 
     indent = (round $ (0.334 :: Float) * fromIntegral x) :: Int
 
-    color :: ((Maybe Int, ByteString), Int)
-                -> (Maybe Int, Style, [ByteString])
-    color ((m, s), i)
-        | i == select && i == playing = f sty3
-        | i == select                 = f sty2
-        | i == playing                = f sty1
-        | otherwise                   = (m, defaultSty, [s])
-        where
-            f sty = (m, sty,
-                [s, spaces (x - indent - 1 - displayWidth s)])
+    (sty1, sty2, sty3) = (selected cs, cursors cs, combined cs)
+        where cs = config st
 
-    sty1 = selected . config $ st
-    sty2 = cursors  . config $ st
-    sty3 = combined . config $ st
+    color :: ((Maybe Int, ByteString), Int)
+                -> (Maybe Int, (Style, [ByteString]))
+    color ((m, s), i) = (m,) case (i == select, i == playing) of
+        (True, True) -> f sty3
+        (True, _)    -> f sty2
+        (_   , True) -> f sty1
+        _            -> (defaultSty, [s])
+      where
+        f sty = (sty, [s, spaces (x - indent - 1 - displayWidth s)])
 
-    drawIt :: (Maybe Int, Style, [ByteString]) -> StringA
-    drawIt (Nothing, sty, v) =
+    drawIt :: (Maybe Int, (Style, [ByteString])) -> StringA
+    drawIt (Nothing, (sty, v)) =
         FancyS $ map (, sty) $ spaces (1 + indent) : v
-
-    drawIt (Just i, sty, v) = FancyS
+    drawIt (Just i, (sty, v)) = FancyS
         $ (d, sty')
         : (spaces (indent + 1 - displayWidth d), sty')
         : map (, sty) v
       where
         sty' = if sty == sty2 || sty == sty3 then sty2 else sty1
-        d = toMaxWidth (indent - 1) $ takeFileName
-                $ case size st of
-                    0 -> "(empty)"
-                    _ -> dname $ folders st ! i
+        d = toMaxWidth (indent - 1) $ takeFileName $ dname $ folders st ! i
 
 ------------------------------------------------------------------------
 
@@ -459,7 +438,6 @@
     drawLine $ progressBar dd
     Curses.wMove Curses.stdScr 2 0   -- hardcoded!
     drawLine $ pTimes dd
-    when (h < 45) $ renderModals st (Size h w) -- small screen modals paint over clock
 
 ------------------------------------------------------------------------
 -- | General modal renderer.
@@ -467,11 +445,11 @@
 renderModal st (Size h w) mkr = do
     let (mw, modal') = mkr w
         hoffset = max 0 $ (w - mw) `div` 2
-        mlines  = min h $ length modal'
-        voffset = (h - mlines) `div` 2
+        vislines = (h - 5) `min` length modal'
+        voffset = ((h - vislines) `div` 2) `max` 4
         sty = modals $ config st
     Curses.wMove Curses.stdScr voffset hoffset
-    for_ (take mlines modal') \t -> do
+    for_ (take vislines modal') \t -> do
         drawLine $ Fast (toWidth mw t) sty
         (y', _) <- Curses.getYX Curses.stdScr
         Curses.wMove Curses.stdScr (y'+1) hoffset
@@ -485,9 +463,7 @@
         ExitModal   -> exitModal
 
 ------------------------------------------------------------------------
---
 -- | Draw the screen
---
 redraw :: Draw
 redraw = Draw $ discardErrors {- TODO what errors are discarded? -} do
     st <- getsHS id    -- another refresh could be triggered?
@@ -517,9 +493,7 @@
         -- todo rendering bug here when deleting backwards in minibuffer
 
 ------------------------------------------------------------------------
---
 -- | Draw a coloured (or not) string to the screen
---
 drawLine :: StringA -> IO ()
 drawLine (Fast ps sty) = drawSegment ps sty
 drawLine (FancyS ls)   = traverse_ (uncurry drawSegment) ls
diff --git a/Width.hs b/Width.hs
--- a/Width.hs
+++ b/Width.hs
@@ -7,8 +7,8 @@
 
 import Base
 
-import qualified Data.ByteString.Char8 as P
-import qualified Data.ByteString.UTF8 as UTF8
+import Data.ByteString.Char8 qualified as P
+import Data.ByteString.UTF8 qualified as UTF8
 
 import Foreign.C.Types
 
diff --git a/app/Main.hs b/app/Main.hs
--- a/app/Main.hs
+++ b/app/Main.hs
@@ -8,15 +8,14 @@
 import Base
 
 import Core     (start, shutdown, Options(..))
-import qualified Config
+import Config qualified
 import Keymap   (keyLoop)
-import Tree     (buildTree, isEmpty)
+import Playlist (buildPlaylist, isEmpty)
 
-import System.IO            (hPrint, stderr)
-import System.Posix.Signals (installHandler, sigTERM, sigPIPE, sigINT, sigHUP
-                            ,sigALRM, sigABRT, Handler(Ignore, Default, Catch))
+import System.Posix.Signals (installHandler, Handler(Ignore, Default, Catch),
+                             sigTERM, sigPIPE, sigINT, sigHUP , sigALRM, sigABRT)
 
-import qualified Data.ByteString.UTF8 as UTF8
+import Data.ByteString.UTF8 qualified as UTF8
 
 import Options.Applicative
 
@@ -35,8 +34,7 @@
 exitHandler :: IO ()
 exitHandler = do
     releaseSignals  -- in case shutdown itself gets stuck
-    catch @SomeException (shutdown Nothing) (hPrint stderr)
-    exitWith $ ExitFailure 1
+    shutdown $ Just "Killed"
 
 releaseSignals :: IO ()
 releaseSignals =
@@ -56,6 +54,12 @@
         <*> optional (strOption -- temporarily internal since feature needs work
             (long "config" <> short 'c' <> metavar "FILE" <> internal
                 <> help "Read this config file instead of the XDG default"))
+        <*> optional (option (maybeReader prefixMatch) (
+            long "mode" <> short 'm' <> metavar "MODE"
+                <> help "Initial play mode (default: last selected, or once)"))
+        <*> option auto (
+            long "history" <> short 'h' <> metavar "NUM" <> value 61
+                <> help "Size of play history, up to 61 selectable" <> showDefault)
     files = some $ argument (UTF8.fromString <$> str) (metavar "FILE|DIR...")
 
 parserInfo :: ParserInfo (Options, [ByteString])
@@ -67,17 +71,25 @@
     versionOpt = infoOption Config.versinfo
         (hidden <> long "version" <> short 'V' <> help "Show version information")
 
+-- XXX should this have tests?
+prefixMatch :: (Enum a, Bounded a, Show a) => String -> Maybe a
+prefixMatch s =
+    case [ x | x <- [minBound .. maxBound], s' `isPrefixOf` map toLower (show x) ] of
+        [x] -> Just x
+        _   -> Nothing
+    where s' = map toLower s
+
 ------------------------------------------------------------------------
 
 main :: IO ()
 main = do
     (opts, args) <- customExecParser (prefs showHelpOnEmpty) parserInfo
-    tree <- buildTree args
-    when (isEmpty tree) $
+    list <- buildPlaylist args
+    when (isEmpty list) $
         errorWithoutStackTrace "Error: No music files found."
     initSignals
     err <- either id absurd <$> try @SomeException do
-        start opts tree
+        start opts list
         keyLoop
     shutdown $ Just $ "Error: " ++ show err
 
diff --git a/hmp3-ng.cabal b/hmp3-ng.cabal
--- a/hmp3-ng.cabal
+++ b/hmp3-ng.cabal
@@ -1,6 +1,6 @@
 cabal-version: 3.0
 name: hmp3-ng
-version: 2.18.1
+version: 2.19.0
 synopsis: A 2019 fork of an ncurses mp3 player written in Haskell
 description:
   An mp3 player with a curses frontend.  Playlists are populated by
@@ -24,7 +24,7 @@
   location: https://github.com/galenhuntington/hmp3-ng
 
 common opts
-  default-language: Haskell2010
+  default-language: GHC2021
   default-extensions:
     BlockArguments
     MultiWayIf
@@ -33,18 +33,10 @@
     -- In GHC2024
     DerivingStrategies
     LambdaCase
-    -- In GHC2021 (soon!)
-    BangPatterns
-    GeneralizedNewtypeDeriving
-    NamedFieldPuns
-    NumericUnderscores
-    ScopedTypeVariables
-    StandaloneDeriving
-    TupleSections
-    TypeApplications
 
   ghc-options:
     -Wall
+    -Wprepositive-qualified-module
     -funbox-strict-fields
 
 library
@@ -54,13 +46,12 @@
     Base
     Config
     Core
+    Decoder
     Keyboard
     Keymap
-    Lexer
+    Playlist
     State
     Style
-    Syntax
-    Tree
     UI
     Width
 
@@ -75,7 +66,7 @@
 
   build-depends:
     array,
-    base >=4.15 && <5,
+    base >=4.16 && <5,
     bytestring >=0.10,
     clock,
     containers,
@@ -83,11 +74,11 @@
     filepath,
     hscurses,
     mtl,
-    pcre-light >=0.3,
     posix-paths,
     process,
     random,
-    unix >=2.7,
+    regex-posix,
+    unix >=2.8,
     utf8-string,
 
 executable hmp3
@@ -97,7 +88,6 @@
   ghc-options: -threaded
   build-depends:
     base,
-    bytestring,
     hmp3-ng,
     optparse-applicative,
     unix,
@@ -109,11 +99,12 @@
   main-is: Main.hs
   hs-source-dirs: test
   other-modules:
+    BaseSpec
     ConfigSpec
     CoreSpec
-    LexerSpec
+    DecoderSpec
+    PlaylistSpec
     StyleSpec
-    TreeSpec
     WidthSpec
 
   build-depends:
diff --git a/test/BaseSpec.hs b/test/BaseSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/BaseSpec.hs
@@ -0,0 +1,30 @@
+module BaseSpec (tests) where
+
+import Data.ByteString.UTF8 qualified as UTF8
+
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Base (matches)
+
+tests :: TestTree
+tests = testGroup "Base"
+    [ testGroup "match"
+        [ t True "exact"         "foo"       "fooBar"
+        , t True "caseless"      "FOO"       "fooBar"
+        , t False "invalid"      "["         "any[thing"
+        , t True "alt"           "(foo|az)Q" "bazQux"
+        , t True "dot"           "o.a"       "foobar"
+        , t True "Unicode"       "jör"       "Björk"
+        , t True "dot Unicode"   "j.r"       "Björk"
+        , t True "Nordic case"   "bør"       "BØrnE"
+        , t True "Greek case"    "Λω"        "ΦλΩα"
+        , t True "dot CJK"       "中.人"     "中國人"
+        ]
+    ]
+
+
+t :: Bool -> String -> String -> String -> TestTree
+t b tag pat str =
+    testCase tag $ matches (UTF8.fromString pat) (UTF8.fromString str) @?= b
+
diff --git a/test/DecoderSpec.hs b/test/DecoderSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/DecoderSpec.hs
@@ -0,0 +1,63 @@
+module DecoderSpec (tests) where
+
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Data.ByteString.Char8 qualified as P
+
+import Decoder
+
+-- These exercise the helpers doX, 'trim', and 'normalise'.
+tests :: TestTree
+tests = testGroup "Lexer.mpgParser"
+    [ testGroup "status (@P)"
+        [ tc "@P 0"  $ Right (P Stopped)
+        , tc "@P 1"  $ Right (P Paused)
+        , tc "@P 2"  $ Right (P Playing)
+        , tc "@P 3"  $ Left Nothing   -- end-of-song marker: ignored (no double play)
+        , tc "@P 9"  $ Left Nothing   -- unknown code
+        , tc "@P "   $ Left Nothing   -- no payload
+        ]
+    , testGroup "frame (@F)"
+        [ tc "@F 123 456 12.34 56.78" $ Right (F (Frame 123 456 12.34 56.78))
+        , tc "@F 0 0 0.00 -1.00"      $ Right (F (Frame 0 0 0.00 0))  -- timeLeft clamped
+        , tc "@F 1 2 3.00"            $ Left Nothing   -- too few fields
+        , tc "@F a b c d"             $ Left Nothing   -- non-numeric
+        ]
+    , testGroup "stream info (@S)"
+        [ tc "@S 1.0 1 44100 stereo 0 0 2 0 0 0 128 0"
+                                      $ Right (S "mpeg 1.0 128kb/s 44kHz")
+        , tc "@S 1.0 1 44100"         $ Left Nothing   -- too few fields
+        ]
+    , testGroup "id3 (@I)" (let s = "n\195\182rmalise" in
+        [ tcId3 ["Title"]
+              $ Right (I (Id3 "Title" "" "" "Title"))
+        , tcId3 ["Title", "Artist"]
+              $ Right (I (Id3 "Title" "Artist" "" "Artist : Title"))
+        , tcId3 [" Title", "  Artist"]
+              $ Right (I (Id3 "Title" "Artist" "" "Artist : Title"))
+        , tcId3 ["Title", "Artist", "Album"]
+              $ Right (I (Id3 "Title" "Artist" "Album" "Artist : Album : Title"))
+        , tcId3 ["", "Artist"]       $ Left Nothing   -- blank title: skipped
+        , tc "@I song.mp3"           $ Left Nothing   -- non-ID3 @I: don't overwrite
+        , tc "@I {"                  $ Left Nothing   -- grouping marker: ignored
+        , tcId3 ["nörmalise"]        $ Right (I (Id3 s "" "" s))
+        , tcId3 [s]                  $ Right (I (Id3 s "" "" s))
+        ])
+    , testGroup "tagline, errors, junk"
+        [ tc "@E some failure"       $ Left (Just "some failure")
+        , tc "garbage"               $ Left Nothing   -- no @ prefix
+        , tc "@F"                    $ Left Nothing   -- no space after code
+        , tc "@"                     $ Left Nothing
+        , tc ""                      $ Left Nothing
+        ]
+    ]
+  where
+    tc line = tc' (show line) line
+    tc' tag line expected = testCase tag $ mpgParser line @?= expected
+    tcId3 fields = tc' (show fields) (id3 fields)
+
+-- | Build an "@I ID3:" line from fixed-width 30-char fields, as mpg123 emits.
+id3 :: [P.ByteString] -> P.ByteString
+id3 fields = "@I ID3:" <> mconcat [ P.take 30 (f <> P.replicate 30 ' ') | f <- fields ]
+
diff --git a/test/LexerSpec.hs b/test/LexerSpec.hs
deleted file mode 100644
--- a/test/LexerSpec.hs
+++ /dev/null
@@ -1,65 +0,0 @@
-module LexerSpec (tests) where
-
-import Test.Tasty
-import Test.Tasty.HUnit
-
-import qualified Data.ByteString.Char8 as P
-
-import Lexer (mpgParser)
-import Syntax
-
--- These exercise the helpers doX, 'trim', and 'normalise'.
-tests :: TestTree
-tests = testGroup "Lexer.mpgParser"
-    [ testGroup "status (@P)"
-        [ tc "@P 0"  $ Right (S Stopped)
-        , tc "@P 1"  $ Right (S Paused)
-        , tc "@P 2"  $ Right (S Playing)
-        , tc "@P 3"  $ Left Nothing   -- end-of-song marker: ignored (no double play)
-        , tc "@P 9"  $ Left Nothing   -- unknown code
-        , tc "@P "   $ Left Nothing   -- no payload
-        ]
-    , testGroup "frame (@F)"
-        [ tc "@F 123 456 12.34 56.78" $ Right (R (Frame 123 456 12.34 56.78))
-        , tc "@F 0 0 0.00 -1.00"      $ Right (R (Frame 0 0 0.00 0))  -- timeLeft clamped
-        , tc "@F 1 2 3.00"            $ Left Nothing   -- too few fields
-        , tc "@F a b c d"             $ Left Nothing   -- non-numeric
-        ]
-    , testGroup "stream info (@S)"
-        [ tc "@S 1.0 1 44100 stereo 0 0 2 0 0 0 128 0"
-                                      $ Right (I (Info "mpeg 1.0 128kbit/s 44kHz"))
-        , tc "@S 1.0 1 44100"         $ Left Nothing   -- too few fields
-        ]
-    , testGroup "id3 (@I)" (let s = "n\195\182rmalise" in
-        [ tcId3 ["Title"]
-              $ Right (F (Id3 "Title" "" "" "Title"))
-        , tcId3 ["Title", "Artist"]
-              $ Right (F (Id3 "Title" "Artist" "" "Artist : Title"))
-        , tcId3 [" Title", "  Artist"]
-              $ Right (F (Id3 "Title" "Artist" "" "Artist : Title"))
-        , tcId3 ["Title", "Artist", "Album"]
-              $ Right (F (Id3 "Title" "Artist" "Album" "Artist : Album : Title"))
-        , tcId3 ["", "Artist"]       $ Left Nothing   -- blank title: skipped
-        , tc "@I song.mp3"           $ Left Nothing   -- non-ID3 @I: don't overwrite
-        , tc "@I {"                  $ Left Nothing   -- grouping marker: ignored
-        , tcId3 ["nörmalise"]        $ Right (F (Id3 s "" "" s))
-        , tcId3 [s]                  $ Right (F (Id3 s "" "" s))
-        ])
-    , testGroup "tagline, errors, junk"
-        [ tc "@R a tagline"          $ Right (T Tag)
-        , tc "@E some failure"       $ Left (Just "some failure")
-        , tc "garbage"               $ Left Nothing   -- no @ prefix
-        , tc "@F"                    $ Left Nothing   -- no space after code
-        , tc "@"                     $ Left Nothing
-        , tc ""                      $ Left Nothing
-        ]
-    ]
-  where
-    tc line = tc' (show line) line
-    tc' tag line expected = testCase tag $ mpgParser line @?= expected
-    tcId3 fields = tc' (show fields) (id3 fields)
-
--- | Build an "@I ID3:" line from fixed-width 30-char fields, as mpg123 emits.
-id3 :: [P.ByteString] -> P.ByteString
-id3 fields = "@I ID3:" <> mconcat [ P.take 30 (f <> P.replicate 30 ' ') | f <- fields ]
-
diff --git a/test/Main.hs b/test/Main.hs
--- a/test/Main.hs
+++ b/test/Main.hs
@@ -2,20 +2,22 @@
 
 import Test.Tasty
 
-import qualified ConfigSpec
-import qualified CoreSpec
-import qualified LexerSpec
-import qualified StyleSpec
-import qualified TreeSpec
-import qualified WidthSpec
+import BaseSpec qualified
+import ConfigSpec qualified
+import CoreSpec qualified
+import DecoderSpec qualified
+import PlaylistSpec qualified
+import StyleSpec qualified
+import WidthSpec qualified
 
 main :: IO ()
 main = defaultMain $ testGroup "hmp3-ng"
-    [ ConfigSpec.tests
+    [ BaseSpec.tests
+    , ConfigSpec.tests
     , CoreSpec.tests
-    , LexerSpec.tests
+    , DecoderSpec.tests
     , StyleSpec.tests
-    , TreeSpec.tests
+    , PlaylistSpec.tests
     , WidthSpec.tests
     ]
 
diff --git a/test/PlaylistSpec.hs b/test/PlaylistSpec.hs
new file mode 100644
--- /dev/null
+++ b/test/PlaylistSpec.hs
@@ -0,0 +1,39 @@
+module PlaylistSpec (tests) where
+
+import Test.Tasty
+import Test.Tasty.HUnit
+
+import Playlist (doOrphans, merge)
+
+tests :: TestTree
+tests = testGroup "Playlist"
+    [ testGroup "doOrphans"
+        [ testCase "empty"
+            $ doOrphans []                       @?= []
+        , testCase "bare filename"
+            $ doOrphans ["song.mp3"]             @?= [(".", ["song.mp3"])]
+        , testCase "single directory"
+            $ doOrphans ["a/song.mp3"]           @?= [("a", ["song.mp3"])]
+        , testCase "nested directory"
+            $ doOrphans ["a/b/song.mp3"]         @?= [("a/b", ["song.mp3"])]
+        , testCase "multiple, no merging here"
+            $ doOrphans ["a/x.mp3", "a/y.mp3"]   @?= [("a", ["x.mp3"]), ("a", ["y.mp3"])]
+        ]
+    , testGroup "merge"
+        [ testCase "empty"
+            $ merge []                                           @?= []
+        , testCase "singleton passes through"
+            $ merge [("a", ["x"])]                               @?= [("a", ["x"])]
+        , testCase "different keys are sorted"
+            $ merge [("b", ["1"]), ("a", ["2"])]                 @?= [("a", ["2"]), ("b", ["1"])]
+        , testCase "same key combines values"
+            $ merge [("a", ["x"]), ("a", ["y"])]                 @?= [("a", ["x", "y"])]
+        , testCase "preserves value order within a key"
+            $ merge [("a", ["1"]), ("a", ["2"]), ("a", ["3"])]   @?= [("a", ["1", "2", "3"])]
+        , testCase "value lists with multiple elements"
+            $ merge [("a", ["x", "y"]), ("a", ["z"])]            @?= [("a", ["x", "y", "z"])]
+        , testCase "interleaved keys"
+            $ merge [("a", ["1"]), ("b", ["2"]), ("a", ["3"])]   @?= [("a", ["1", "3"]), ("b", ["2"])]
+        ]
+    ]
+
diff --git a/test/TreeSpec.hs b/test/TreeSpec.hs
deleted file mode 100644
--- a/test/TreeSpec.hs
+++ /dev/null
@@ -1,38 +0,0 @@
-module TreeSpec (tests) where
-
-import Test.Tasty
-import Test.Tasty.HUnit
-
-import Tree (doOrphans, merge)
-
-tests :: TestTree
-tests = testGroup "Tree"
-    [ testGroup "doOrphans"
-        [ testCase "empty"
-            $ doOrphans []                       @?= []
-        , testCase "bare filename"
-            $ doOrphans ["song.mp3"]             @?= [(".", ["song.mp3"])]
-        , testCase "single directory"
-            $ doOrphans ["a/song.mp3"]           @?= [("a", ["song.mp3"])]
-        , testCase "nested directory"
-            $ doOrphans ["a/b/song.mp3"]         @?= [("a/b", ["song.mp3"])]
-        , testCase "multiple, no merging here"
-            $ doOrphans ["a/x.mp3", "a/y.mp3"]   @?= [("a", ["x.mp3"]), ("a", ["y.mp3"])]
-        ]
-    , testGroup "merge"
-        [ testCase "empty"
-            $ merge []                                           @?= []
-        , testCase "singleton passes through"
-            $ merge [("a", ["x"])]                               @?= [("a", ["x"])]
-        , testCase "different keys are sorted"
-            $ merge [("b", ["1"]), ("a", ["2"])]                 @?= [("a", ["2"]), ("b", ["1"])]
-        , testCase "same key combines values"
-            $ merge [("a", ["x"]), ("a", ["y"])]                 @?= [("a", ["x", "y"])]
-        , testCase "preserves value order within a key"
-            $ merge [("a", ["1"]), ("a", ["2"]), ("a", ["3"])]   @?= [("a", ["1", "2", "3"])]
-        , testCase "value lists with multiple elements"
-            $ merge [("a", ["x", "y"]), ("a", ["z"])]            @?= [("a", ["x", "y", "z"])]
-        , testCase "interleaved keys"
-            $ merge [("a", ["1"]), ("b", ["2"]), ("a", ["3"])]   @?= [("a", ["1", "3"]), ("b", ["2"])]
-        ]
-    ]
diff --git a/test/WidthSpec.hs b/test/WidthSpec.hs
--- a/test/WidthSpec.hs
+++ b/test/WidthSpec.hs
@@ -4,7 +4,7 @@
 import Test.Tasty.HUnit
 
 import Data.ByteString (ByteString)
-import qualified Data.ByteString.UTF8 as UTF8
+import Data.ByteString.UTF8 qualified as UTF8
 
 import Width (displayWidth, toMaxWidth, toWidth)
 
