packages feed

tui-launcher (empty) → 0.0.1

raw patch · 15 files changed

+1506/−0 lines, 15 filesdep +basedep +brickdep +containersbinary-added

Dependencies added: base, brick, containers, directory, filepath, optparse-applicative, process, tasty, tasty-hunit, text, tomland, tuispec, unix, vty, vty-crossplatform

Files

+ CHANGELOG.md view
@@ -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.
+ LICENSE view
@@ -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.
+ README.md view
@@ -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"+```
+ app/Main.hs view
@@ -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
+ docs/screenshots/github-dark-square.png view

binary file changed (absent → 7367 bytes)

+ docs/screenshots/github-dark-vertical.png view

binary file changed (absent → 8197 bytes)

+ docs/screenshots/github-light-square.png view

binary file changed (absent → 8412 bytes)

+ docs/screenshots/github-light-vertical.png view

binary file changed (absent → 9404 bytes)

+ src/TuiLauncher/App.hs view
@@ -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 ()
+ src/TuiLauncher/Config.hs view
@@ -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)
+ src/TuiLauncher/Themes.hs view
@@ -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
+ src/TuiLauncher/Types.hs view
@@ -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)
+ src/TuiLauncher/UI.hs view
@@ -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)
+ test/Main.hs view
@@ -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\""+        ]
+ tui-launcher.cabal view
@@ -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,