diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,10 @@
+# Changelog
+
+## v0.0.1 - 2026-03-17
+
+- Initial public release of `tui-launcher`.
+- Brick-based terminal launcher with keyboard and mouse navigation.
+- TOML configuration with per-entry `working-dir`, `shell-program`, `shell-login`, and `color`.
+- Auto-created default config at `~/.config/tui-launcher/config.toml`.
+- End-to-end TuiSpec test coverage for explicit config loading, arrow-key navigation, and shell launching.
+- Screenshot generation with dark and light terminal renders for the README.
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
--- /dev/null
+++ b/README.md
@@ -0,0 +1,168 @@
+# tui-launcher
+
+[![Hackage](https://img.shields.io/hackage/v/tui-launcher.svg)](https://hackage.haskell.org/package/tui-launcher)
+
+Small Brick-based terminal launcher for shell commands. It reads a TOML config,
+shows a keyboard-driven tile menu, and replaces itself with the selected
+command.
+
+## Screenshots
+
+Both dark and light terminal styles are shown in square and vertical layouts.
+
+![Dark square screenshot](docs/screenshots/github-dark-square.png)
+
+![Dark vertical screenshot](docs/screenshots/github-dark-vertical.png)
+
+![Light square screenshot](docs/screenshots/github-light-square.png)
+
+![Light vertical screenshot](docs/screenshots/github-light-vertical.png)
+
+## Installation
+
+Install from Hackage:
+
+```sh
+cabal install tui-launcher
+```
+
+Or download the latest release binary from GitHub Releases and put it on your
+`PATH`:
+
+```sh
+curl -Lo tui-launcher \
+  https://github.com/TharkunAB/tui-launcher/releases/latest/download/tui-launcher-linux-x86_64
+chmod +x tui-launcher
+install -Dm755 tui-launcher ~/.local/bin/tui-launcher
+```
+
+Then run:
+
+```sh
+tui-launcher
+```
+
+On first launch, `tui-launcher` creates `~/.config/tui-launcher/config.toml` if
+it does not already exist.
+
+## Features
+
+- Arrow keys and `hjkl` move the selection
+- `Enter` launches the selected entry
+- Mouse click launches an entry and mouse wheel scrolls
+- Dedicated `Exit` button at the bottom of the launcher
+- Optional per-entry `color` for tile text
+- Per-entry `working-dir`, `shell-program`, and `shell-login`
+- Auto-created default config at `~/.config/tui-launcher/config.toml`
+- Uses your terminal's default colors
+
+## Configuration
+
+Use the default config path:
+
+```sh
+tui-launcher
+```
+
+Or point at an explicit file:
+
+```sh
+tui-launcher --config /path/to/config.toml
+```
+
+Example config:
+
+```toml
+[layout]
+tile-width = 20
+tile-height = 5
+tile-spacing = 1
+
+# [shell]
+# program = "/bin/bash"
+# login = false
+
+[[entries]]
+name = "Shell"
+command = "exec \"${SHELL:-/bin/sh}\""
+color = "bright-blue"
+
+[[entries]]
+name = "nvim"
+command = "nvim"
+color = "blue"
+working-dir = "~/Code"
+
+[[entries]]
+name = "Codex"
+command = "codex"
+color = "bright-magenta"
+
+[[entries]]
+name = "Claude"
+command = "claude"
+color = "orange"
+
+[[entries]]
+name = "Tmux"
+command = "tmux"
+color = "cyan"
+working-dir = "~/Code/project"
+shell-program = "/bin/zsh"
+shell-login = true
+```
+
+Layout settings:
+
+- `tile-width`: positive integer, default `20`
+- `tile-height`: positive integer, default `5`
+- `tile-spacing`: non-negative integer, default `1`
+
+Shell resolution order:
+
+- `entries.shell-program`
+- `[shell].program`
+- `$SHELL`
+- `/bin/sh`
+
+Relative `working-dir` resolution:
+
+- default config: relative to `$HOME`
+- `--config /path/to/config.toml`: relative to that config file's directory
+
+Supported entry colors:
+
+- `black`
+- `red`
+- `orange`
+- `green`
+- `yellow`
+- `blue`
+- `magenta`
+- `cyan`
+- `white`
+- `bright-black`
+- `bright-red`
+- `bright-green`
+- `bright-yellow`
+- `bright-blue`
+- `bright-magenta`
+- `bright-cyan`
+- `bright-white`
+
+## Development
+
+Use the provided Nix shell for the project toolchain:
+
+```sh
+nix-shell
+```
+
+Common commands:
+
+```sh
+make build
+make test
+make lint
+nix-shell --command "make snapshots"
+```
diff --git a/app/Main.hs b/app/Main.hs
new file mode 100644
--- /dev/null
+++ b/app/Main.hs
@@ -0,0 +1,8 @@
+-- | Executable entry point for @tui-launcher@.
+module Main (main) where
+
+import TuiLauncher.App qualified as App
+
+-- | Run the application.
+main :: IO ()
+main = App.main
diff --git a/docs/screenshots/github-dark-square.png b/docs/screenshots/github-dark-square.png
new file mode 100644
Binary files /dev/null and b/docs/screenshots/github-dark-square.png differ
diff --git a/docs/screenshots/github-dark-vertical.png b/docs/screenshots/github-dark-vertical.png
new file mode 100644
Binary files /dev/null and b/docs/screenshots/github-dark-vertical.png differ
diff --git a/docs/screenshots/github-light-square.png b/docs/screenshots/github-light-square.png
new file mode 100644
Binary files /dev/null and b/docs/screenshots/github-light-square.png differ
diff --git a/docs/screenshots/github-light-vertical.png b/docs/screenshots/github-light-vertical.png
new file mode 100644
Binary files /dev/null and b/docs/screenshots/github-light-vertical.png differ
diff --git a/src/TuiLauncher/App.hs b/src/TuiLauncher/App.hs
new file mode 100644
--- /dev/null
+++ b/src/TuiLauncher/App.hs
@@ -0,0 +1,64 @@
+-- | Application startup and process handoff.
+module TuiLauncher.App (
+    main,
+    launch,
+) where
+
+import Control.Exception (SomeException, displayException, try)
+import Data.Text qualified as T
+import Options.Applicative
+import System.Directory (setCurrentDirectory)
+import System.Exit (exitFailure)
+import System.IO (hPutStrLn, stderr)
+import System.Posix.Process (executeFile)
+import TuiLauncher.Config (loadResolvedConfig)
+import TuiLauncher.Types
+import TuiLauncher.UI (runUi)
+
+{- | Parse CLI options, load configuration, run the UI, and launch the
+selected entry when one is chosen.
+-}
+main :: IO ()
+main = do
+    options <- execParser parserInfo
+    resolved <- loadResolvedConfig (appConfigOverride options)
+    selected <- runUi (resolvedConfig resolved)
+    maybe (pure ()) launch selected
+
+-- | Parser metadata for the command-line interface.
+parserInfo :: ParserInfo AppOptions
+parserInfo =
+    info
+        (optionsParser <**> helper)
+        (fullDesc <> progDesc "Launch shell commands from a Brick-based menu")
+
+-- | Supported command-line options.
+optionsParser :: Parser AppOptions
+optionsParser =
+    AppOptions
+        <$> optional
+            ( strOption
+                ( long "config"
+                    <> metavar "PATH"
+                    <> help "Override the default config path"
+                )
+            )
+
+{- | Replace the current process with the selected command.
+
+The launcher optionally changes directory first and then executes the
+resolved shell with either @-c@ or @-lc@ depending on the login setting.
+-}
+launch :: LaunchSpec -> IO ()
+launch LaunchSpec{..} = do
+    let shellArgs = (if shellLogin launchShell then ["-lc"] else ["-c"]) <> [T.unpack launchCommand]
+    result <- try @SomeException do
+        maybe (pure ()) setCurrentDirectory launchWorkingDir
+        _ <- executeFile (shellProgram launchShell) True shellArgs Nothing
+        pure ()
+    case result of
+        Left err -> do
+            hPutStrLn stderr ("Failed to launch command: " <> displayException err)
+            exitFailure
+        Right _ ->
+            pure ()
diff --git a/src/TuiLauncher/Config.hs b/src/TuiLauncher/Config.hs
new file mode 100644
--- /dev/null
+++ b/src/TuiLauncher/Config.hs
@@ -0,0 +1,291 @@
+{-# LANGUAGE MultilineStrings #-}
+
+-- | Config file loading, validation, and normalization.
+module TuiLauncher.Config (
+    defaultConfigText,
+    loadResolvedConfig,
+) where
+
+import Control.Applicative ((<|>))
+import Control.Monad (unless, when)
+import Data.List (isPrefixOf)
+import Data.List.NonEmpty (NonEmpty (..))
+import Data.Maybe (fromMaybe)
+import Data.Text qualified as T
+import Data.Text.IO qualified as TIO
+import System.Directory (
+    createDirectoryIfMissing,
+    doesFileExist,
+    getHomeDirectory,
+ )
+import System.Environment (lookupEnv)
+import System.FilePath (isAbsolute, takeDirectory, (</>))
+import Toml qualified
+import TuiLauncher.Types
+
+{- | Starter config written on first launch when no default config exists.
+
+The generated file keeps only the shell entry enabled and leaves the other
+starter entries in place as commented examples.
+-}
+defaultConfigText :: T.Text
+defaultConfigText =
+    """
+    [layout]
+    tile-width = 20
+    tile-height = 5
+    tile-spacing = 1
+
+    # [shell]
+    # program = "/bin/bash"
+    # login = false
+
+    [[entries]]
+    name = "Shell"
+    command = "exec \\\"${SHELL:-/bin/sh}\\\""
+    # color = "bright-blue"
+
+    # [[entries]]
+    # name = "nvim"
+    # command = "nvim"
+    # color = "blue"
+    # working-dir = "~/Code"
+
+    # [[entries]]
+    # name = "Tmux"
+    # command = "tmux"
+    # color = "cyan"
+    # working-dir = "~/Code/project"
+    # shell-program = "/bin/zsh"
+    # shell-login = true
+
+    # [[entries]]
+    # name = "Codex"
+    # command = "codex"
+    # color = "bright-magenta"
+
+    # [[entries]]
+    # name = "Claude"
+    # command = "claude"
+    # color = "orange"
+    """
+
+-- | Load, parse, validate, and normalize the active config file.
+loadResolvedConfig :: Maybe FilePath -> IO ResolvedConfig
+loadResolvedConfig overridePath = do
+    home <- getHomeDirectory
+    (configLocation, configPath, autoCreated) <- resolveConfigPath home overridePath
+    decoded <- Toml.decodeFileEither rawConfigCodec configPath
+    rawConfig <- either (fail . T.unpack . Toml.prettyTomlDecodeErrors) pure decoded
+    validated <- validateConfig home configLocation rawConfig
+    pure
+        ResolvedConfig
+            { resolvedPath = configPath
+            , resolvedLocation = configLocation
+            , resolvedAutoCreated = autoCreated
+            , resolvedConfig = validated
+            }
+
+-- | Resolve which config file to use and create the default one if needed.
+resolveConfigPath :: FilePath -> Maybe FilePath -> IO (ConfigLocation, FilePath, Bool)
+resolveConfigPath home = \case
+    Nothing -> do
+        let defaultPath = home </> ".config" </> "tui-launcher" </> "config.toml"
+        exists <- doesFileExist defaultPath
+        if exists
+            then pure (DefaultConfig defaultPath, defaultPath, False)
+            else do
+                createDirectoryIfMissing True (takeDirectory defaultPath)
+                TIO.writeFile defaultPath defaultConfigText
+                pure (DefaultConfig defaultPath, defaultPath, True)
+    Just path -> do
+        expanded <- expandPath home path
+        exists <- doesFileExist expanded
+        unless exists $
+            fail ("Config file does not exist: " <> expanded)
+        pure (OverrideConfig expanded, expanded, False)
+
+-- | Expand a leading @~@ against the supplied home directory.
+expandPath :: FilePath -> FilePath -> IO FilePath
+expandPath home path
+    | "~/" `isPrefixOf` path = pure (home </> drop 2 path)
+    | path == "~" = pure home
+    | otherwise = pure path
+
+-- | Validate raw TOML data into the application config used by the UI.
+validateConfig :: FilePath -> ConfigLocation -> RawConfig -> IO AppConfig
+validateConfig home location RawConfig{..} = do
+    layout <- validateLayout rawLayout
+    entries <- validateEntries home location rawShell rawEntries
+    pure
+        AppConfig
+            { configLayout = layout
+            , configEntries = entries
+            }
+
+-- | Validate layout values and apply defaults for missing settings.
+validateLayout :: Maybe RawLayout -> IO LayoutConfig
+validateLayout maybeLayout = do
+    let raw = fromMaybe (RawLayout Nothing Nothing Nothing) maybeLayout
+        width = fromMaybe (layoutTileWidth defaultLayout) (rawTileWidth raw)
+        height = fromMaybe (layoutTileHeight defaultLayout) (rawTileHeight raw)
+        spacing = fromMaybe (layoutTileSpacing defaultLayout) (rawTileSpacing raw)
+    when (width <= 0) $ fail "layout.tile-width must be positive"
+    when (height <= 0) $ fail "layout.tile-height must be positive"
+    when (spacing < 0) $ fail "layout.tile-spacing must be non-negative"
+    pure
+        LayoutConfig
+            { layoutTileWidth = width
+            , layoutTileHeight = height
+            , layoutTileSpacing = spacing
+            }
+
+-- | Validate and resolve all configured entries.
+validateEntries :: FilePath -> ConfigLocation -> Maybe RawShell -> [EntryConfig] -> IO (NonEmpty ResolvedEntry)
+validateEntries home location globalShell entries = do
+    resolvedEntries <- traverse (resolveEntry home location globalShell) entries
+    case resolvedEntries of
+        [] -> fail "At least one [[entries]] table is required"
+        entry : rest -> pure (entry :| rest)
+
+-- | Resolve a single entry into the runtime format used by the launcher.
+resolveEntry :: FilePath -> ConfigLocation -> Maybe RawShell -> EntryConfig -> IO ResolvedEntry
+resolveEntry home location globalShell EntryConfig{..} = do
+    let strippedName = T.strip entryName
+        strippedCommand = T.strip entryCommand
+    when (T.null strippedName) $ fail "entries.name must not be empty"
+    when (T.null strippedCommand) $ fail "entries.command must not be empty"
+    color <- traverse resolveEntryColor entryColor
+    workingDir <- traverse (resolveWorkingDir home location) entryWorkingDir
+    shellProgramText <- resolveShellProgram home globalShell entryShellProgram
+    let login = fromMaybe (maybe False (fromMaybe False . rawShellLogin) globalShell) entryShellLogin
+    pure
+        ResolvedEntry
+            { resolvedName = strippedName
+            , resolvedCommand = strippedCommand
+            , resolvedColor = color
+            , resolvedWorkingDirDisplay = fmap (displayWorkingDir home) workingDir
+            , resolvedWorkingDir = workingDir
+            , resolvedShell =
+                ShellConfig
+                    { shellProgram = T.unpack shellProgramText
+                    , shellLogin = login
+                    }
+            }
+
+-- | Resolve the shell program for an entry using the configured precedence.
+resolveShellProgram :: FilePath -> Maybe RawShell -> Maybe T.Text -> IO T.Text
+resolveShellProgram _home globalOverride entryOverride =
+    case entryOverride <|> (globalOverride >>= rawShellProgram) of
+        Just program -> validateShellProgram program
+        Nothing -> do
+            envShell <- lookupShell
+            pure $
+                case fmap (T.strip . T.pack) envShell of
+                    Just shellPath | not (T.null shellPath) -> shellPath
+                    _ -> "/bin/sh"
+
+-- | Read the current shell from the environment.
+lookupShell :: IO (Maybe FilePath)
+lookupShell = lookupEnv "SHELL"
+
+-- | Reject blank shell program values.
+validateShellProgram :: T.Text -> IO T.Text
+validateShellProgram rawProgram = do
+    let stripped = T.strip rawProgram
+    when (T.null stripped) $
+        fail "shell.program and entries.shell-program must not be empty"
+    pure stripped
+
+-- | Resolve an entry working directory relative to the proper base path.
+resolveWorkingDir :: FilePath -> ConfigLocation -> T.Text -> IO FilePath
+resolveWorkingDir home location rawPath = do
+    let strippedPath = T.strip rawPath
+    when (T.null strippedPath) $
+        fail "entries.working-dir must not be empty"
+    let pathString = T.unpack strippedPath
+    expanded <- expandPath home pathString
+    if isAbsolute expanded
+        then pure expanded
+        else pure (baseDirectory location home </> expanded)
+
+-- | Determine the base directory for relative path resolution.
+baseDirectory :: ConfigLocation -> FilePath -> FilePath
+baseDirectory location home = case location of
+    DefaultConfig _ -> home
+    OverrideConfig path -> takeDirectory path
+
+-- | Render a resolved working directory with @~@ for the user's home.
+displayWorkingDir :: FilePath -> FilePath -> T.Text
+displayWorkingDir home path
+    | path == home = "~"
+    | (home <> "/") `isPrefixOf` path =
+        T.pack ("~/" <> drop (length home + 1) path)
+    | otherwise = T.pack path
+
+-- | Validate and normalize an optional entry color.
+resolveEntryColor :: T.Text -> IO EntryColor
+resolveEntryColor rawColor = do
+    let normalized = T.toLower (T.strip rawColor)
+    when (T.null normalized) $
+        fail "entries.color must not be empty"
+    case normalized of
+        "black" -> pure EntryBlack
+        "red" -> pure EntryRed
+        "orange" -> pure EntryOrange
+        "green" -> pure EntryGreen
+        "yellow" -> pure EntryYellow
+        "blue" -> pure EntryBlue
+        "magenta" -> pure EntryMagenta
+        "cyan" -> pure EntryCyan
+        "white" -> pure EntryWhite
+        "bright-black" -> pure EntryBrightBlack
+        "bright-red" -> pure EntryBrightRed
+        "bright-green" -> pure EntryBrightGreen
+        "bright-yellow" -> pure EntryBrightYellow
+        "bright-blue" -> pure EntryBrightBlue
+        "bright-magenta" -> pure EntryBrightMagenta
+        "bright-cyan" -> pure EntryBrightCyan
+        "bright-white" -> pure EntryBrightWhite
+        "gray" -> pure EntryBrightBlack
+        "grey" -> pure EntryBrightBlack
+        other ->
+            fail
+                ( "Unsupported entries.color: "
+                    <> T.unpack other
+                    <> ". Use an ANSI color name like red, bright-blue, or cyan."
+                )
+
+-- | TOML codec for the top-level config structure.
+rawConfigCodec :: Toml.TomlCodec RawConfig
+rawConfigCodec =
+    RawConfig
+        <$> (Toml.dioptional (Toml.table rawLayoutCodec "layout") Toml..= rawLayout)
+        <*> (Toml.dioptional (Toml.table rawShellCodec "shell") Toml..= rawShell)
+        <*> (Toml.list entryCodec "entries" Toml..= rawEntries)
+
+-- | TOML codec for the layout table.
+rawLayoutCodec :: Toml.TomlCodec RawLayout
+rawLayoutCodec =
+    RawLayout
+        <$> (Toml.dioptional (Toml.int "tile-width") Toml..= rawTileWidth)
+        <*> (Toml.dioptional (Toml.int "tile-height") Toml..= rawTileHeight)
+        <*> (Toml.dioptional (Toml.int "tile-spacing") Toml..= rawTileSpacing)
+
+-- | TOML codec for global shell defaults.
+rawShellCodec :: Toml.TomlCodec RawShell
+rawShellCodec =
+    RawShell
+        <$> (Toml.dioptional (Toml.text "program") Toml..= rawShellProgram)
+        <*> (Toml.dioptional (Toml.bool "login") Toml..= rawShellLogin)
+
+-- | TOML codec for launcher entries.
+entryCodec :: Toml.TomlCodec EntryConfig
+entryCodec =
+    EntryConfig
+        <$> (Toml.text "name" Toml..= entryName)
+        <*> (Toml.text "command" Toml..= entryCommand)
+        <*> (Toml.dioptional (Toml.text "color") Toml..= entryColor)
+        <*> (Toml.dioptional (Toml.text "working-dir") Toml..= entryWorkingDir)
+        <*> (Toml.dioptional (Toml.text "shell-program") Toml..= entryShellProgram)
+        <*> (Toml.dioptional (Toml.bool "shell-login") Toml..= entryShellLogin)
diff --git a/src/TuiLauncher/Themes.hs b/src/TuiLauncher/Themes.hs
new file mode 100644
--- /dev/null
+++ b/src/TuiLauncher/Themes.hs
@@ -0,0 +1,122 @@
+-- | Brick attribute definitions that defer colors to the active terminal theme.
+module TuiLauncher.Themes (
+    accentAttr,
+    attrMapForTerminal,
+    baseAttr,
+    borderAttr,
+    entryCommandAttr,
+    exitAttr,
+    exitFocusedAttr,
+    focusedAttr,
+    focusedTitleAttr,
+    mutedAttr,
+) where
+
+import Brick (AttrMap, AttrName, attrMap, attrName)
+import Graphics.Vty qualified as V
+import TuiLauncher.Types (AppConfig (..), EntryColor (..), ResolvedEntry (..))
+
+-- | Default text and background styling.
+baseAttr :: AttrName
+baseAttr = attrName "base"
+
+-- | Border styling for tiles and layout chrome.
+borderAttr :: AttrName
+borderAttr = attrName "border"
+
+-- | Secondary text styling for commands.
+mutedAttr :: AttrName
+mutedAttr = attrName "muted"
+
+-- | Accent styling for headings.
+accentAttr :: AttrName
+accentAttr = attrName "accent"
+
+-- | Styling for the currently focused tile.
+focusedAttr :: AttrName
+focusedAttr = attrName "focused"
+
+-- | Styling for the title inside the currently focused tile.
+focusedTitleAttr :: AttrName
+focusedTitleAttr = attrName "focused.title"
+
+-- | Styling for the exit button when it is not selected.
+exitAttr :: AttrName
+exitAttr = attrName "exit"
+
+-- | Styling for the exit button when it is selected.
+exitFocusedAttr :: AttrName
+exitFocusedAttr = attrName "exit.focused"
+
+-- | Attribute name for the colored command text inside an entry tile.
+entryCommandAttr :: Maybe EntryColor -> AttrName
+entryCommandAttr maybeColor =
+    attrName ("entry.command." <> maybe "default" renderEntryColor maybeColor)
+
+-- | Build the Brick attribute map using terminal-default colors.
+attrMapForTerminal :: AppConfig -> AttrMap
+attrMapForTerminal config =
+    attrMap
+        V.defAttr
+        ( [ (baseAttr, V.defAttr)
+          , (borderAttr, V.withForeColor V.defAttr V.brightBlack)
+          , (mutedAttr, V.withStyle V.defAttr V.dim)
+          , (accentAttr, V.withStyle V.defAttr V.bold)
+          , (focusedAttr, V.withStyle V.defAttr V.bold)
+          , (focusedTitleAttr, V.withStyle V.defAttr V.bold)
+          , (exitAttr, V.withForeColor V.defAttr V.brightBlack)
+          , (exitFocusedAttr, V.withStyle V.defAttr V.bold)
+          ]
+            <> concatMap entryColorAttrs (configEntries config)
+        )
+
+-- | Build text attrs for an entry's configured color.
+entryColorAttrs :: ResolvedEntry -> [(AttrName, V.Attr)]
+entryColorAttrs entry =
+    case resolvedColor entry of
+        Nothing -> [(entryCommandAttr Nothing, V.withStyle V.defAttr V.dim)]
+        Just colorValue ->
+            let color = entryColorToVty colorValue
+             in [(entryCommandAttr (Just colorValue), V.withForeColor V.defAttr color)]
+
+-- | Render an entry color to a stable attribute suffix.
+renderEntryColor :: EntryColor -> String
+renderEntryColor = \case
+    EntryBlack -> "black"
+    EntryRed -> "red"
+    EntryOrange -> "orange"
+    EntryGreen -> "green"
+    EntryYellow -> "yellow"
+    EntryBlue -> "blue"
+    EntryMagenta -> "magenta"
+    EntryCyan -> "cyan"
+    EntryWhite -> "white"
+    EntryBrightBlack -> "bright-black"
+    EntryBrightRed -> "bright-red"
+    EntryBrightGreen -> "bright-green"
+    EntryBrightYellow -> "bright-yellow"
+    EntryBrightBlue -> "bright-blue"
+    EntryBrightMagenta -> "bright-magenta"
+    EntryBrightCyan -> "bright-cyan"
+    EntryBrightWhite -> "bright-white"
+
+-- | Map an entry color to the corresponding Vty color.
+entryColorToVty :: EntryColor -> V.Color
+entryColorToVty = \case
+    EntryBlack -> V.black
+    EntryRed -> V.red
+    EntryOrange -> V.rgbColor (255 :: Int) 165 0
+    EntryGreen -> V.green
+    EntryYellow -> V.yellow
+    EntryBlue -> V.blue
+    EntryMagenta -> V.magenta
+    EntryCyan -> V.cyan
+    EntryWhite -> V.white
+    EntryBrightBlack -> V.brightBlack
+    EntryBrightRed -> V.brightRed
+    EntryBrightGreen -> V.brightGreen
+    EntryBrightYellow -> V.brightYellow
+    EntryBrightBlue -> V.brightBlue
+    EntryBrightMagenta -> V.brightMagenta
+    EntryBrightCyan -> V.brightCyan
+    EntryBrightWhite -> V.brightWhite
diff --git a/src/TuiLauncher/Types.hs b/src/TuiLauncher/Types.hs
new file mode 100644
--- /dev/null
+++ b/src/TuiLauncher/Types.hs
@@ -0,0 +1,195 @@
+-- | Shared configuration and UI domain types.
+module TuiLauncher.Types (
+    AppConfig (..),
+    AppOptions (..),
+    ConfigLocation (..),
+    EntryColor (..),
+    EntryConfig (..),
+    GridMetrics (..),
+    LayoutConfig (..),
+    LaunchSpec (..),
+    RawConfig (..),
+    RawLayout (..),
+    RawShell (..),
+    ResolvedConfig (..),
+    ResolvedEntry (..),
+    ShellConfig (..),
+    defaultLayout,
+) where
+
+import Data.List.NonEmpty (NonEmpty)
+import Data.Text (Text)
+
+-- | Command-line options accepted by the executable.
+newtype AppOptions = AppOptions
+    { appConfigOverride :: Maybe FilePath
+    -- ^ Optional explicit config path from @--config@.
+    }
+    deriving (Eq, Show)
+
+-- | Where the active configuration file was loaded from.
+data ConfigLocation
+    = -- | The default config under @~/.config/tui-launcher/config.toml@.
+      DefaultConfig FilePath
+    | -- | A path supplied explicitly via @--config@.
+      OverrideConfig FilePath
+    deriving (Eq, Show)
+
+-- | Supported ANSI text colors for entry labels and commands.
+data EntryColor
+    = EntryBlack
+    | EntryRed
+    | EntryOrange
+    | EntryGreen
+    | EntryYellow
+    | EntryBlue
+    | EntryMagenta
+    | EntryCyan
+    | EntryWhite
+    | EntryBrightBlack
+    | EntryBrightRed
+    | EntryBrightGreen
+    | EntryBrightYellow
+    | EntryBrightBlue
+    | EntryBrightMagenta
+    | EntryBrightCyan
+    | EntryBrightWhite
+    deriving (Bounded, Enum, Eq, Ord, Show)
+
+-- | Raw layout settings parsed directly from TOML.
+data RawLayout = RawLayout
+    { rawTileWidth :: Maybe Int
+    -- ^ Optional tile width override.
+    , rawTileHeight :: Maybe Int
+    -- ^ Optional tile height override.
+    , rawTileSpacing :: Maybe Int
+    -- ^ Optional tile spacing override.
+    }
+    deriving (Eq, Show)
+
+-- | Raw shell defaults parsed directly from TOML.
+data RawShell = RawShell
+    { rawShellProgram :: Maybe Text
+    -- ^ Optional shell executable.
+    , rawShellLogin :: Maybe Bool
+    -- ^ Optional login-shell flag.
+    }
+    deriving (Eq, Show)
+
+-- | Raw entry table parsed directly from TOML.
+data EntryConfig = EntryConfig
+    { entryName :: Text
+    -- ^ Label shown in the UI.
+    , entryCommand :: Text
+    -- ^ Command passed to the selected shell.
+    , entryColor :: Maybe Text
+    -- ^ Optional text color for this entry.
+    , entryWorkingDir :: Maybe Text
+    -- ^ Optional working directory before launch.
+    , entryShellProgram :: Maybe Text
+    -- ^ Optional per-entry shell override.
+    , entryShellLogin :: Maybe Bool
+    -- ^ Optional per-entry login-shell flag.
+    }
+    deriving (Eq, Show)
+
+-- | Top-level TOML structure before validation and normalization.
+data RawConfig = RawConfig
+    { rawLayout :: Maybe RawLayout
+    -- ^ Optional layout table.
+    , rawShell :: Maybe RawShell
+    -- ^ Optional global shell defaults.
+    , rawEntries :: [EntryConfig]
+    -- ^ Launcher entries in display order.
+    }
+    deriving (Eq, Show)
+
+-- | Validated layout settings used by the UI.
+data LayoutConfig = LayoutConfig
+    { layoutTileWidth :: Int
+    -- ^ Width of each tile in terminal cells.
+    , layoutTileHeight :: Int
+    -- ^ Height of each tile in terminal cells.
+    , layoutTileSpacing :: Int
+    -- ^ Horizontal and vertical spacing between tiles.
+    }
+    deriving (Eq, Show)
+
+-- | Built-in default layout used when the config omits layout values.
+defaultLayout :: LayoutConfig
+defaultLayout =
+    LayoutConfig
+        { layoutTileWidth = 20
+        , layoutTileHeight = 5
+        , layoutTileSpacing = 1
+        }
+
+-- | Shell program and flags used to execute a launch command.
+data ShellConfig = ShellConfig
+    { shellProgram :: FilePath
+    -- ^ Executable name or path for the shell.
+    , shellLogin :: Bool
+    -- ^ Whether the shell should be invoked as a login shell.
+    }
+    deriving (Eq, Show)
+
+-- | A fully validated entry ready for display in the UI.
+data ResolvedEntry = ResolvedEntry
+    { resolvedName :: Text
+    -- ^ Label rendered in the tile.
+    , resolvedCommand :: Text
+    -- ^ Command string passed to the shell.
+    , resolvedColor :: Maybe EntryColor
+    -- ^ Optional terminal color used for the tile text.
+    , resolvedWorkingDirDisplay :: Maybe Text
+    -- ^ Optional working directory label rendered in the tile.
+    , resolvedWorkingDir :: Maybe FilePath
+    -- ^ Optional working directory applied before launch.
+    , resolvedShell :: ShellConfig
+    -- ^ Resolved shell configuration for this entry.
+    }
+    deriving (Eq, Show)
+
+-- | Validated application configuration consumed by the UI.
+data AppConfig = AppConfig
+    { configLayout :: LayoutConfig
+    -- ^ Layout parameters for the tile grid.
+    , configEntries :: NonEmpty ResolvedEntry
+    -- ^ Entries shown in the launcher.
+    }
+    deriving (Eq, Show)
+
+-- | Full result of loading and validating the configuration file.
+data ResolvedConfig = ResolvedConfig
+    { resolvedPath :: FilePath
+    -- ^ Final path used to load the config.
+    , resolvedLocation :: ConfigLocation
+    -- ^ Whether the path was default or overridden.
+    , resolvedAutoCreated :: Bool
+    -- ^ Whether the default config had to be created on startup.
+    , resolvedConfig :: AppConfig
+    -- ^ Validated application configuration.
+    }
+    deriving (Eq, Show)
+
+-- | Launch request returned by the UI when the user selects an entry.
+data LaunchSpec = LaunchSpec
+    { launchCommand :: Text
+    -- ^ Command string passed to the shell.
+    , launchWorkingDir :: Maybe FilePath
+    -- ^ Optional working directory to switch to before launch.
+    , launchShell :: ShellConfig
+    -- ^ Shell configuration used to execute the command.
+    }
+    deriving (Eq, Show)
+
+-- | Derived grid sizing information for a particular terminal size.
+data GridMetrics = GridMetrics
+    { gridColumns :: Int
+    -- ^ Number of tile columns that fit horizontally.
+    , gridVisibleRows :: Int
+    -- ^ Number of tile rows visible without scrolling.
+    , gridTotalRows :: Int
+    -- ^ Total number of tile rows in the entry list.
+    }
+    deriving (Eq, Show)
diff --git a/src/TuiLauncher/UI.hs b/src/TuiLauncher/UI.hs
new file mode 100644
--- /dev/null
+++ b/src/TuiLauncher/UI.hs
@@ -0,0 +1,426 @@
+-- | Brick user interface for browsing and selecting launcher entries.
+module TuiLauncher.UI (
+    runUi,
+) where
+
+import Brick (
+    App (..),
+    BrickEvent (..),
+    EventM,
+    Padding (Pad),
+    Widget,
+    clickable,
+    customMain,
+    fill,
+    hBox,
+    hLimit,
+    padBottom,
+    padRight,
+    padTop,
+    txt,
+    vBox,
+    vLimit,
+    withAttr,
+    withBorderStyle,
+    (<=>),
+ )
+import Brick.Main qualified as Brick
+import Brick.Types (get, put)
+import Brick.Widgets.Border (border)
+import Brick.Widgets.Border.Style (unicode, unicodeBold)
+import Brick.Widgets.Center (center, hCenter)
+import Data.List.NonEmpty qualified as NonEmpty
+import Data.Text (Text)
+import Data.Text qualified as T
+import Graphics.Vty qualified as V
+import Graphics.Vty.Config (defaultConfig)
+import Graphics.Vty.CrossPlatform (mkVty)
+import TuiLauncher.Themes
+import TuiLauncher.Types
+
+-- | Resource names used by Brick for clickable tiles.
+data Name
+    = TileName Int
+    | ExitName
+    deriving (Eq, Ord, Show)
+
+-- | Fixed width of the exit button.
+exitButtonWidth :: Int
+exitButtonWidth = 20
+
+-- | Fixed height of the exit button.
+exitButtonHeight :: Int
+exitButtonHeight = 3
+
+-- | Spacing between the tile grid and the exit button.
+exitButtonSpacing :: Int
+exitButtonSpacing = 1
+
+-- | Full UI state tracked during the Brick event loop.
+data UiState = UiState
+    { uiConfig :: AppConfig
+    -- ^ Validated application config.
+    , uiSelected :: Int
+    -- ^ Index of the currently focused entry.
+    , uiScrollRow :: Int
+    -- ^ First visible row in the grid.
+    , uiWidth :: Int
+    -- ^ Last known terminal width.
+    , uiHeight :: Int
+    -- ^ Last known terminal height.
+    , uiLaunch :: Maybe LaunchSpec
+    -- ^ Launch request chosen by the user, if any.
+    }
+
+-- | Run the Brick UI and return the selected launch request, if any.
+runUi :: AppConfig -> IO (Maybe LaunchSpec)
+runUi config = do
+    initialVty <- buildVty
+    (w, h) <- V.displayBounds (V.outputIface initialVty)
+    finalState <-
+        customMain
+            initialVty
+            buildVty
+            Nothing
+            app
+            UiState
+                { uiConfig = config
+                , uiSelected = 0
+                , uiScrollRow = 0
+                , uiWidth = w
+                , uiHeight = h
+                , uiLaunch = Nothing
+                }
+    pure (uiLaunch finalState)
+
+-- | Build a Vty instance with mouse reporting enabled.
+buildVty :: IO V.Vty
+buildVty = do
+    vty <- mkVty defaultConfig
+    V.setMode (V.outputIface vty) V.Mouse True
+    pure vty
+
+-- | Brick application definition.
+app :: App UiState e Name
+app =
+    App
+        { appDraw = drawUi
+        , appChooseCursor = Brick.neverShowCursor
+        , appHandleEvent = handleEvent
+        , appStartEvent = pure ()
+        , appAttrMap = attrMapForTerminal . uiConfig
+        }
+
+-- | Draw the complete screen.
+drawUi :: UiState -> [Widget Name]
+drawUi state =
+    [ withAttr baseAttr $
+        header state
+            <=> padTop (Pad 1) (drawBody state)
+    , withAttr baseAttr (fill ' ')
+    ]
+
+-- | Render the application title.
+header :: UiState -> Widget Name
+header _state =
+    withAttr accentAttr $
+        hCenter $
+            txt "tui-launcher"
+
+-- | Render the grid body or the small-window fallback.
+drawBody :: UiState -> Widget Name
+drawBody state
+    | tooSmall = center $ withAttr mutedAttr $ txt "Window too small"
+    | otherwise =
+        vBox
+            [ vBox (fmap hCenter visibleRowsWidgets)
+            , fill ' '
+            , padTop (Pad exitButtonSpacing) $
+                hCenter $
+                    drawExitButton state
+            ]
+  where
+    layout = configLayout (uiConfig state)
+    metrics = gridMetrics layout (uiWidth state) (uiHeight state) (entryCount state)
+    tooSmall =
+        max (layoutTileWidth layout) exitButtonWidth > uiWidth state
+            || minimumHeight layout > uiHeight state
+    entries = zip [0 ..] (NonEmpty.toList (configEntries (uiConfig state)))
+    rows = chunk (gridColumns metrics) entries
+    visibleRows = take (gridVisibleRows metrics) (drop (uiScrollRow state) rows)
+    visibleRowsWidgets = zipWith (drawRow state) [uiScrollRow state ..] visibleRows
+
+-- | Render one visible row of tiles.
+drawRow :: UiState -> Int -> [(Int, ResolvedEntry)] -> Widget Name
+drawRow state _rowIndex entriesInRow =
+    padBottom (Pad (layoutTileSpacing (configLayout (uiConfig state)))) $
+        hBox $
+            zipWith
+                ( \position tileWidget ->
+                    if position == length entriesInRow - 1
+                        then tileWidget
+                        else padRight (Pad (layoutTileSpacing (configLayout (uiConfig state)))) tileWidget
+                )
+                [0 ..]
+                (fmap (uncurry (drawTile state)) entriesInRow)
+
+-- | Render a single selectable tile.
+drawTile :: UiState -> Int -> ResolvedEntry -> Widget Name
+drawTile state index entry =
+    clickable (TileName index) $
+        hLimit (layoutTileWidth layout) $
+            vLimit (layoutTileHeight layout) $
+                withBorderStyle borderStyle $
+                    withAttr borderStyleAttr $
+                        border $
+                            withAttr bodyStyleAttr $
+                                vBox
+                                    [ vLimit topPad (fill ' ')
+                                    , center $
+                                        withAttr titleStyleAttr $
+                                            txt titleText
+                                    , center $
+                                        withAttr commandStyleAttr $
+                                            txt $
+                                                truncateText innerWidth (resolvedCommand entry)
+                                    , drawWorkingDirLine
+                                    , fill ' '
+                                    ]
+  where
+    layout = configLayout (uiConfig state)
+    isSelected = index == uiSelected state
+    innerWidth = max 1 (layoutTileWidth layout - 2)
+    innerHeight = max 1 (layoutTileHeight layout - 2)
+    lineCount = 2 + maybe 0 (const 1) (resolvedWorkingDirDisplay entry)
+    topPad = max 0 ((innerHeight - lineCount) `div` 2)
+    borderStyle = if isSelected then unicodeBold else unicode
+    borderStyleAttr = if isSelected then focusedAttr else borderAttr
+    bodyStyleAttr = baseAttr
+    titleStyleAttr =
+        if isSelected
+            then focusedTitleAttr
+            else mutedAttr
+    commandStyleAttr = entryCommandAttr (resolvedColor entry)
+    titleText =
+        if isSelected
+            then "> " <> resolvedName entry
+            else resolvedName entry
+    drawWorkingDirLine =
+        case resolvedWorkingDirDisplay entry of
+            Nothing -> fill ' '
+            Just workingDirText ->
+                center $
+                    withAttr mutedAttr $
+                        txt (truncateText innerWidth workingDirText)
+
+-- | Render the fixed exit button shown below the launcher entries.
+drawExitButton :: UiState -> Widget Name
+drawExitButton state =
+    clickable ExitName $
+        hLimit exitButtonWidth $
+            vLimit exitButtonHeight $
+                withBorderStyle borderStyle $
+                    withAttr borderStyleAttr $
+                        border $
+                            withAttr baseAttr $
+                                center $
+                                    withAttr textStyleAttr $
+                                        txt labelText
+  where
+    isSelected = uiSelected state == exitSelectionIndex state
+    borderStyle = if isSelected then unicodeBold else unicode
+    borderStyleAttr =
+        if isSelected
+            then exitFocusedAttr
+            else exitAttr
+    textStyleAttr =
+        if isSelected
+            then focusedTitleAttr
+            else mutedAttr
+    labelText =
+        if isSelected
+            then "> Exit"
+            else "Exit"
+
+-- | Handle keyboard, mouse, and resize events.
+handleEvent :: BrickEvent Name e -> EventM Name UiState ()
+handleEvent event = do
+    state <- get
+    case event of
+        VtyEvent (V.EvResize width height) ->
+            put $ normalizeScroll state{uiWidth = width, uiHeight = height}
+        VtyEvent (V.EvKey V.KEnter []) ->
+            put (launchSelected state) >> Brick.halt
+        VtyEvent (V.EvKey V.KEsc []) ->
+            Brick.halt
+        VtyEvent (V.EvKey (V.KChar 'q') []) ->
+            Brick.halt
+        VtyEvent (V.EvKey V.KLeft []) ->
+            put $ moveHorizontal (-1) state
+        VtyEvent (V.EvKey (V.KChar 'h') []) ->
+            put $ moveHorizontal (-1) state
+        VtyEvent (V.EvKey V.KRight []) ->
+            put $ moveHorizontal 1 state
+        VtyEvent (V.EvKey (V.KChar 'l') []) ->
+            put $ moveHorizontal 1 state
+        VtyEvent (V.EvKey V.KUp []) ->
+            put $ moveVertical (-1) state
+        VtyEvent (V.EvKey (V.KChar 'k') []) ->
+            put $ moveVertical (-1) state
+        VtyEvent (V.EvKey V.KDown []) ->
+            put $ moveVertical 1 state
+        VtyEvent (V.EvKey (V.KChar 'j') []) ->
+            put $ moveVertical 1 state
+        MouseDown (TileName index) V.BLeft [] _ ->
+            put (launchByIndex index state) >> Brick.halt
+        MouseDown ExitName V.BLeft [] _ ->
+            Brick.halt
+        MouseDown _ V.BScrollDown [] _ ->
+            put $ scrollRows 1 state
+        MouseDown _ V.BScrollUp [] _ ->
+            put $ scrollRows (-1) state
+        _ -> pure ()
+
+-- | Launch the currently selected entry.
+launchSelected :: UiState -> UiState
+launchSelected state
+    | uiSelected state == exitSelectionIndex state = state
+    | otherwise = launchByIndex (uiSelected state) state
+
+-- | Convert an entry index into the corresponding launch request.
+launchByIndex :: Int -> UiState -> UiState
+launchByIndex index state =
+    state
+        { uiSelected = boundedIndex
+        , uiLaunch =
+            Just
+                LaunchSpec
+                    { launchCommand = resolvedCommand entry
+                    , launchWorkingDir = resolvedWorkingDir entry
+                    , launchShell = resolvedShell entry
+                    }
+        }
+  where
+    entries = NonEmpty.toList (configEntries (uiConfig state))
+    boundedIndex = clamp 0 (length entries - 1) index
+    entry = entries !! boundedIndex
+
+-- | Move the focused tile left or right.
+moveHorizontal :: Int -> UiState -> UiState
+moveHorizontal delta state =
+    normalizeScroll $
+        case uiSelected state of
+            selectedIndex
+                | selectedIndex == exitSelectionIndex state -> state
+                | otherwise ->
+                    state
+                        { uiSelected =
+                            clamp 0 (entryCount state - 1) (selectedIndex + delta)
+                        }
+
+-- | Move the focused tile up or down by one grid row.
+moveVertical :: Int -> UiState -> UiState
+moveVertical delta state
+    | delta == 0 = state
+    | uiSelected state == exitSelectionIndex state && delta < 0 =
+        normalizeScroll state{uiSelected = max 0 (entryCount state - 1)}
+    | uiSelected state == exitSelectionIndex state =
+        state
+    | delta > 0 =
+        normalizeScroll $
+            state
+                { uiSelected =
+                    if candidate < entryCount state
+                        then candidate
+                        else exitSelectionIndex state
+                }
+    | otherwise =
+        normalizeScroll $
+            state
+                { uiSelected =
+                    clamp 0 (entryCount state - 1) candidate
+                }
+  where
+    metrics = gridMetrics (configLayout (uiConfig state)) (uiWidth state) (uiHeight state) (entryCount state)
+    candidate = uiSelected state + delta * gridColumns metrics
+
+-- | Scroll the viewport by a number of rows.
+scrollRows :: Int -> UiState -> UiState
+scrollRows delta state =
+    state{uiScrollRow = clamp 0 maxScroll (uiScrollRow state + delta)}
+  where
+    metrics = gridMetrics (configLayout (uiConfig state)) (uiWidth state) (uiHeight state) (entryCount state)
+    maxScroll = max 0 (gridTotalRows metrics - gridVisibleRows metrics)
+
+-- | Keep the selected tile visible after movement or resize events.
+normalizeScroll :: UiState -> UiState
+normalizeScroll state =
+    state{uiScrollRow = clamp 0 maxScroll targetScroll}
+  where
+    metrics = gridMetrics (configLayout (uiConfig state)) (uiWidth state) (uiHeight state) (entryCount state)
+    maxScroll = max 0 (gridTotalRows metrics - gridVisibleRows metrics)
+    selectedIndex = min (uiSelected state) (max 0 (entryCount state - 1))
+    selectedRow = selectedIndex `div` gridColumns metrics
+    targetScroll
+        | selectedRow < uiScrollRow state = selectedRow
+        | selectedRow >= uiScrollRow state + gridVisibleRows metrics =
+            selectedRow - gridVisibleRows metrics + 1
+        | otherwise = uiScrollRow state
+
+-- | Compute grid dimensions for the current terminal size and layout.
+gridMetrics :: LayoutConfig -> Int -> Int -> Int -> GridMetrics
+gridMetrics layout width height totalEntries =
+    GridMetrics
+        { gridColumns = max 1 columns
+        , gridVisibleRows = max 1 visibleRows
+        , gridTotalRows = max 1 totalRows
+        }
+  where
+    outerWidth = layoutTileWidth layout + layoutTileSpacing layout
+    outerHeight = layoutTileHeight layout + layoutTileSpacing layout
+    bodyHeight = max 1 (height - 2 - exitSectionHeight)
+    columns = max 1 ((width + layoutTileSpacing layout) `div` max 1 outerWidth)
+    visibleRows = max 1 ((bodyHeight + layoutTileSpacing layout) `div` max 1 outerHeight)
+    totalRows = ceilingDiv totalEntries columns
+    exitSectionHeight = exitButtonHeight + exitButtonSpacing
+
+-- | Count the configured entries.
+entryCount :: UiState -> Int
+entryCount = length . NonEmpty.toList . configEntries . uiConfig
+
+-- | Sentinel selection index used for the exit button.
+exitSelectionIndex :: UiState -> Int
+exitSelectionIndex = entryCount
+
+-- | Minimum terminal height required to show one tile row and the exit button.
+minimumHeight :: LayoutConfig -> Int
+minimumHeight layout =
+    2 + layoutTileHeight layout + exitButtonSpacing + exitButtonHeight
+
+-- | Divide and round up, returning a safe minimum of one.
+ceilingDiv :: Int -> Int -> Int
+ceilingDiv numerator denominator
+    | denominator <= 0 = 1
+    | numerator <= 0 = 1
+    | otherwise = (numerator + denominator - 1) `div` denominator
+
+-- | Split a list into equally sized chunks.
+chunk :: Int -> [a] -> [[a]]
+chunk size xs
+    | size <= 0 = [xs]
+    | null xs = []
+    | otherwise =
+        let (prefix, rest) = splitAt size xs
+         in prefix : chunk size rest
+
+-- | Truncate text to fit a tile, appending an ellipsis when needed.
+truncateText :: Int -> Text -> Text
+truncateText width textValue
+    | width <= 0 = ""
+    | T.length textValue <= width = textValue
+    | width == 1 = "…"
+    | otherwise = T.take (width - 1) textValue <> "…"
+
+-- | Clamp a value to an inclusive range.
+clamp :: Int -> Int -> Int -> Int
+clamp lower upper value = max lower (min upper value)
diff --git a/test/Main.hs b/test/Main.hs
new file mode 100644
--- /dev/null
+++ b/test/Main.hs
@@ -0,0 +1,87 @@
+-- | End-to-end TUISpec coverage for @tui-launcher@.
+module Main (main) where
+
+import Data.Text qualified as T
+import Data.Text.IO qualified as TIO
+import System.Directory (
+    createDirectoryIfMissing,
+    findExecutable,
+    getTemporaryDirectory,
+ )
+import System.FilePath ((</>))
+import Test.Tasty (TestTree, defaultMain, testGroup)
+import TuiSpec
+
+-- | Run the TUISpec test suite.
+main :: IO ()
+main = defaultMain tests
+
+-- | Full test tree.
+tests :: TestTree
+tests =
+    testGroup
+        "tui-launcher"
+        [launchesShellEntryFromExplicitConfig]
+
+{- | Verify that @--config@ loads explicit entries, arrow-key navigation moves
+selection, and choosing the @Shell@ entry drops into a real shell.
+-}
+launchesShellEntryFromExplicitConfig :: TestTree
+launchesShellEntryFromExplicitConfig =
+    tuiTest runOptions "launches shell from --config with arrow keys" $ \tui -> do
+        (binaryPath, configPath) <- prepareConfig "tui-launcher-launch" launcherConfig
+        launch tui (app binaryPath ["--config", configPath])
+        waitForText tui (Nth 0 (Exact "tui-launcher"))
+        waitForText tui (Nth 0 (Exact "Logs"))
+        waitForText tui (Nth 0 (Exact "InteractiveShell"))
+        waitForText tui (Nth 0 (Exact "Exit"))
+        press tui ArrowRight
+        press tui Enter
+        sendLine tui "echo 'hello, world'"
+        waitForText tui (Nth 0 (Exact "hello, world"))
+        sendLine tui "exit"
+
+-- | Shared TUISpec runtime options for integration tests.
+runOptions :: RunOptions
+runOptions =
+    defaultRunOptions
+        { timeoutSeconds = 15
+        , terminalCols = 80
+        , terminalRows = 24
+        , artifactsDir = "artifacts/tui-launcher-smoke"
+        }
+
+{- | Write a config file for a test and return the launcher binary plus config
+path.
+-}
+prepareConfig :: FilePath -> T.Text -> IO (FilePath, FilePath)
+prepareConfig testName configText = do
+    tempDir <- getTemporaryDirectory
+    let configDir = tempDir </> testName
+        configPath = configDir </> "config.toml"
+    createDirectoryIfMissing True configDir
+    TIO.writeFile configPath configText
+    exePath <- findExecutable "tui-launcher"
+    case exePath of
+        Nothing -> fail "Could not find tui-launcher executable in PATH"
+        Just binaryPath -> pure (binaryPath, configPath)
+
+-- | Explicit launcher config used by the TUISpec tests.
+launcherConfig :: T.Text
+launcherConfig =
+    T.unlines
+        [ "[layout]"
+        , "tile-width = 18"
+        , "tile-height = 5"
+        , "tile-spacing = 1"
+        , ""
+        , "[[entries]]"
+        , "name = \"Logs\""
+        , "command = \"printf 'not-a-shell\\\\n'; exec sh\""
+        , "color = \"red\""
+        , ""
+        , "[[entries]]"
+        , "name = \"InteractiveShell\""
+        , "command = \"exec /bin/sh\""
+        , "color = \"green\""
+        ]
diff --git a/tui-launcher.cabal b/tui-launcher.cabal
new file mode 100644
--- /dev/null
+++ b/tui-launcher.cabal
@@ -0,0 +1,114 @@
+cabal-version: 3.8
+name: tui-launcher
+version: 0.0.1
+build-type: Simple
+license: MIT
+license-file: LICENSE
+author: tritlo
+maintainer: tritlo
+copyright: 2026 tritlo
+synopsis: Small Brick-based terminal launcher
+description:
+  @tui-launcher@ is a small terminal launcher built with @brick@.
+  .
+  It reads a TOML config file, shows entries as a keyboard- and mouse-driven
+  tile menu, and replaces itself with the selected command.
+  .
+  Features include:
+  .
+  * TOML configuration with auto-created default config
+  * Per-entry @working-dir@, @shell-program@, @shell-login@, and @color@
+  * Arrow-key and @hjkl@ navigation
+  * Mouse selection and scrolling
+  * Configurable tile width, height, and spacing
+
+homepage: https://github.com/TharkunAB/tui-launcher
+bug-reports: https://github.com/TharkunAB/tui-launcher/issues
+tested-with: ghc ==9.12.2
+category: Console
+extra-doc-files:
+  CHANGELOG.md
+  README.md
+  docs/screenshots/github-dark-square.png
+  docs/screenshots/github-dark-vertical.png
+  docs/screenshots/github-light-square.png
+  docs/screenshots/github-light-vertical.png
+
+source-repository head
+  type: git
+  location: https://github.com/TharkunAB/tui-launcher.git
+
+common warnings
+  ghc-options: -Wall
+  default-language: GHC2021
+  default-extensions:
+    BlockArguments
+    LambdaCase
+    OverloadedStrings
+    RecordWildCards
+    TypeApplications
+
+executable tui-launcher
+  import: warnings
+  ghc-options: -threaded
+  main-is: Main.hs
+  hs-source-dirs:
+    app
+    src
+
+  other-modules:
+    TuiLauncher.App
+    TuiLauncher.Config
+    TuiLauncher.Themes
+    TuiLauncher.Types
+    TuiLauncher.UI
+
+  build-depends:
+    base >=4.21 && <5,
+    brick >=2.10 && <2.11,
+    containers >=0.7 && <0.8,
+    directory >=1.3 && <1.4,
+    filepath >=1.5 && <1.6,
+    optparse-applicative >=0.19 && <0.20,
+    process >=1.6 && <1.7,
+    text >=2.1 && <2.2,
+    tomland >=1.3 && <1.4,
+    unix >=2.8 && <2.10,
+    vty >=6.5 && <6.6,
+    vty-crossplatform >=0.5 && <0.6,
+
+test-suite tui-launcher-test
+  import: warnings
+  ghc-options: -threaded
+  type: exitcode-stdio-1.0
+  main-is: Main.hs
+  hs-source-dirs:
+    test
+    src
+
+  build-tool-depends:
+    tui-launcher:tui-launcher
+
+  other-modules:
+    TuiLauncher.App
+    TuiLauncher.Config
+    TuiLauncher.Themes
+    TuiLauncher.Types
+    TuiLauncher.UI
+
+  build-depends:
+    base >=4.21 && <5,
+    brick >=2.10 && <2.11,
+    containers >=0.7 && <0.8,
+    directory >=1.3 && <1.4,
+    filepath >=1.5 && <1.6,
+    optparse-applicative >=0.19 && <0.20,
+    process >=1.6 && <1.7,
+    tasty >=1.5 && <1.6,
+    tasty-hunit >=0.10 && <0.12,
+    text >=2.1 && <2.2,
+    tomland >=1.3 && <1.4,
+    tuispec >=0.2 && <0.3,
+    unix >=2.8 && <2.10,
+    vty >=6.5 && <6.6,
+    vty-crossplatform >=0.5 && <0.6,
