foxygit / Torlinkc Log in
commit 2172d3af484f0281c682d24ed37987857a5653e0
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 11:44:09 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 11:44:09 2026 +0200

    Phase 4: full navigational/functional TUI parity

    Sidebar, interactive Downloads/Seeding lists, the folder and trackers
    prompts, a help overlay, and a splash screen -- all 7 sections from the
    original are now reachable and functional. Visual fidelity is deliberately
    simplified relative to the original's exact Ink box-model layout (dynamic
    row budgeting, per-cell logo sheen, animated progress-bar sheen); this phase
    prioritized getting every keybinding and every screen wired correctly over
    pixel-matching Ink's layout math.

    - ui/app_state.hpp: View (Splash/Browser), Section (merges the original's
      Category and downloads/seeding into one enum), Region (Sidebar/Content),
      Download/SeedFocus for the footer. AppState grows a `history` snapshot
      (from queue.getHistory(), now also published by EngineThread) and
      sidebarLabels (rebuilt every render frame, same "assign in place so
      Menu's binding stays valid" pattern as Phase 3's resultLabels).
    - config/folder.*, config/trackers.*: ported from folder.ts/trackers.ts
      (tilde expansion, tracker list parse/format/status line).
    - util/open_folder.*: xdg-open/gio/open, ported from openFolder.ts (fork+
      execvp+waitpid with a timeout, no Node child_process to lean on).
    - ui/theme.*, ui/move.*, ui/keymap.*, ui/logo.*: ported from theme.ts,
      move.ts, keymap.ts, logo.ts. The color palette lives in a `palette`
      namespace, not `color` -- naming it `color` collided with ftxui::color()
      once both were pulled in via `using namespace`, breaking overload
      resolution on every `| color(...)` in the file.
    - apps/tui/main.cpp: sidebar (Menu with live badge counts), interactive
      Downloads (pause/cancel/retry/redownload/open-folder) and Seeding
      (pause-resume/remove/open-folder) sections with their own cursors, the
      folder/trackers prompts, and the help overlay -- all as ordinary children
      of the same top-level Container::Vertical, routed to via one focusedIndex
      selector kept in sync by syncFocus().

    Deferred to later phases (kept out of scope here, not silently dropped):
    clipboard paste ('m'), copy-magnet ('y'), export-to-.torrent ('s' in
    results/'e' in downloads), the animated progress-bar sheen, and exact
    Ink-equivalent layout spacing.

    Found and fixed three real bugs via manual tmux-driven testing of every
    section, prompt, and transition:
    - AppState::focusedIndex defaulted to 0 (the sidebar) instead of the search
      box Splash needs, so the very first keystrokes on launch went nowhere
      (the sidebar Menu ignores character input).
    - Tab-from-Splash switched view without ever triggering a search, leaving
      focus on an empty, unpopulated results list -- so the next keystrokes
      (e.g. 't' in a query) were swallowed as global shortcuts instead of
      reaching a text field or a populated list.
    - The original design routed modals (folder/trackers/help) through a
      separate Container::Tab selected by a modalIndex. Its close/cancel keys
      did nothing: Tab's event routing did not reliably reach a child with no
      focusable descendant of its own (help's dismiss handler is a bare
      Renderer+CatchEvent). Replaced with plain children of the same
      Container::Vertical already proven to route events correctly by index
      everywhere else in this file. That surfaced a second bug once modals used
      the shared selector: EngineThread's periodic Event::Custom (posted every
      ~500ms to force a redraw) reached help's "any key closes this" handler
      and closed it within one tick of opening, since "any event" included ones
      the app posts to itself -- fixed by excluding Event::Custom specifically.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 apps/tui/main.cpp                         | 703 ++++++++++++++++++++++++++----
 include/torlinkc/config/folder.hpp        |  15 +
 include/torlinkc/config/trackers.hpp      |  19 +
 include/torlinkc/ui/app_state.hpp         |  63 ++-
 include/torlinkc/ui/keymap.hpp            |  31 ++
 include/torlinkc/ui/logo.hpp              |  14 +
 include/torlinkc/ui/move.hpp              |  12 +
 include/torlinkc/ui/search_aggregator.hpp |   8 +-
 include/torlinkc/ui/theme.hpp             |  47 ++
 include/torlinkc/util/open_folder.hpp     |  13 +
 src/CMakeLists.txt                        |   8 +
 src/config/folder.cpp                     |  31 ++
 src/config/trackers.cpp                   |  81 ++++
 src/ui/app_state.cpp                      |  44 ++
 src/ui/engine_thread.cpp                  |   4 +-
 src/ui/keymap.cpp                         |  75 ++++
 src/ui/logo.cpp                           |  61 +++
 src/ui/move.cpp                           |  18 +
 src/ui/search_aggregator.cpp              |  10 +-
 src/ui/theme.cpp                          |  24 +
 src/util/open_folder.cpp                  |  64 +++
 21 files changed, 1242 insertions(+), 103 deletions(-)

diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
index 3b0f55a..29982d9 100644
--- a/apps/tui/main.cpp
+++ b/apps/tui/main.cpp
@@ -1,12 +1,18 @@
-// Phase 3 deliverable: full search parity across all 10 sources, via a real
-// SearchAggregator (one std::jthread per source, cancellable, coalesced
-// flush), plus category tabs, the sort cycle, and dead-seeder filtering.
-// Builds on Phase 2's proven three-thread architecture (UI, engine, search)
-// -- this phase is about breadth of sources/filtering, not new concurrency
-// risk.
+// Phase 4 deliverable: full navigational/functional TUI parity with the
+// original Ink app -- a sidebar with all 7 sections (categories +
+// Downloads + Seeding), interactive Downloads/Seeding lists, the folder and
+// trackers prompts, a help overlay, and a splash screen. Visual fidelity is
+// deliberately simplified relative to the original's exact Ink box-model
+// layout math (dynamic row budgeting, per-cell logo sheen, animated
+// progress-bar sheen) -- see the Phase 4 commit message for what's
+// approximated vs. what's dropped to Phase 6.

 #include <algorithm>
+#include <array>
+#include <cstdlib>
 #include <csignal>
+#include <filesystem>
+#include <iostream>
 #include <string>

 #include <ftxui/component/component.hpp>
@@ -15,16 +21,23 @@
 #include <ftxui/dom/elements.hpp>

 #include "torlinkc/config/config.hpp"
+#include "torlinkc/config/folder.hpp"
+#include "torlinkc/config/trackers.hpp"
 #include "torlinkc/engine/queue.hpp"
 #include "torlinkc/sources/registry.hpp"
 #include "torlinkc/sources/types.hpp"
 #include "torlinkc/ui/app_state.hpp"
 #include "torlinkc/ui/engine_thread.hpp"
 #include "torlinkc/ui/filter.hpp"
+#include "torlinkc/ui/keymap.hpp"
+#include "torlinkc/ui/logo.hpp"
+#include "torlinkc/ui/move.hpp"
 #include "torlinkc/ui/search_aggregator.hpp"
 #include "torlinkc/ui/sort.hpp"
 #include "torlinkc/ui/spinner.hpp"
+#include "torlinkc/ui/theme.hpp"
 #include "torlinkc/util/format.hpp"
+#include "torlinkc/util/open_folder.hpp"

 using namespace ftxui;
 using namespace torlinkc;
@@ -32,32 +45,34 @@ using namespace torlinkc::ui;

 namespace {

+// Container::Vertical child indices (main.cpp owns this ordering). Modals
+// are children of the same container (not a separate Container::Tab) and
+// routed to via the same focusedIndex selector -- see syncFocus() and the
+// class-less comment above mainContainer's construction for why.
+constexpr int kFocusSidebar = 0;
+constexpr int kFocusSearch = 1;
+constexpr int kFocusResults = 2;
+constexpr int kFocusDownloads = 3;
+constexpr int kFocusSeeding = 4;
+constexpr int kFocusSpinner = 5;
+constexpr int kFocusFolderPrompt = 6;
+constexpr int kFocusTrackersPrompt = 7;
+constexpr int kFocusHelp = 8;
+
+const std::array<Section, 7> kSidebarSections = {
+    Section::All, Section::Games, Section::Movies, Section::TV, Section::Anime, Section::Downloads, Section::Seeding,
+};
+
+std::string homeDir() {
+  const char* h = std::getenv("HOME");
+  return h ? h : "/tmp";
+}
+
 std::string formatResultLabel(const TorrentResult& r) {
   return "[" + r.source + "] " + stripControl(r.name) + "  (" + formatBytes(static_cast<double>(r.sizeBytes)) +
          ", seeders=" + std::to_string(r.seeders) + ")";
 }

-Element renderDownloads(const AppState& state) {
-  Elements rows;
-  for (const auto& it : state.items) {
-    rows.push_back(hbox({
-        text(stripControl(it.name).substr(0, 32)) | size(WIDTH, EQUAL, 34),
-        gauge(static_cast<float>(it.progress) / 100.0f) | flex,
-        text(" " + std::to_string(it.progress) + "%  " + formatBytes(it.speed) + "/s  peers=" +
-             std::to_string(it.peers)),
-    }));
-  }
-  for (const auto& s : state.seeds) {
-    rows.push_back(text("[seeding] " + stripControl(s.name).substr(0, 40) + "  " + formatBytes(s.uploadSpeed) +
-                         "/s up") |
-                    dim);
-  }
-  if (state.items.empty() && state.seeds.empty()) {
-    rows.push_back(text("(nothing yet -- select a result and press Enter or 'd' to download)") | dim);
-  }
-  return vbox(std::move(rows));
-}
-
 std::string sourcesStatusLine(const AppState& state) {
   int failed = 0;
   std::string firstError;
@@ -77,12 +92,47 @@ std::string sourcesStatusLine(const AppState& state) {
 }

 std::string filterBarText(const AppState& state) {
-  std::string s = "[" + categoryLabel(state.category) + "]";
+  std::string s = "[" + sectionLabel(state.section) + "]";
   s += state.hideDead ? "  hide-dead:on" : "  hide-dead:off";
   s += "  sort:" + sortLabel(state.sort);
   return s;
 }

+Element renderFooter(const AppState& state) {
+  const auto hints = footerHints(state.region, state.section, state.downloadFocus, state.seedFocus);
+  Elements parts;
+  for (std::size_t i = 0; i < hints.size(); ++i) {
+    if (i > 0) parts.push_back(text("   ") | dim);
+    parts.push_back(text(hints[i].keys) | color(palette::alt));
+    parts.push_back(text(" " + hints[i].label) | dim);
+  }
+  return hbox(std::move(parts));
+}
+
+Element renderHelpOverlay() {
+  Elements groups;
+  for (const auto& g : helpGroups()) {
+    Elements lines;
+    lines.push_back(text(g.title) | bold | color(palette::accent));
+    for (const auto& h : g.hints) {
+      lines.push_back(hbox({
+          text(h.keys) | color(palette::alt) | size(WIDTH, EQUAL, 22),
+          text(h.label) | dim,
+      }));
+    }
+    groups.push_back(vbox(std::move(lines)));
+  }
+  Elements spaced;
+  for (std::size_t i = 0; i < groups.size(); ++i) {
+    if (i > 0) spaced.push_back(text(""));
+    spaced.push_back(groups[i]);
+  }
+  spaced.push_back(text(""));
+  spaced.push_back(text("Your downloaded files always stay on disk.") | dim);
+  spaced.push_back(text("Press any key to close") | dim);
+  return window(text("Keyboard"), vbox(std::move(spaced)));
+}
+
 }  // namespace

 int main() {
@@ -96,11 +146,56 @@ int main() {
   EngineThread engine(screen, state);
   SearchAggregator aggregator(screen, state);

+  auto syncFocus = [&] {
+    if (state.showHelp) {
+      state.focusedIndex = kFocusHelp;
+      return;
+    }
+    if (state.editingFolder) {
+      state.focusedIndex = kFocusFolderPrompt;
+      return;
+    }
+    if (state.editingTrackers) {
+      state.focusedIndex = kFocusTrackersPrompt;
+      return;
+    }
+    if (state.view == View::Splash) {
+      state.focusedIndex = kFocusSearch;
+      return;
+    }
+    if (state.region == Region::Sidebar) {
+      state.focusedIndex = kFocusSidebar;
+      return;
+    }
+    switch (state.section) {
+      case Section::Downloads:
+        state.focusedIndex = kFocusDownloads;
+        break;
+      case Section::Seeding:
+        state.focusedIndex = kFocusSeeding;
+        break;
+      default:
+        state.focusedIndex = kFocusResults;
+        break;
+    }
+  };
+
+  // The sidebar Menu's own highlighted row is driven by sidebarCursor, not
+  // section -- anything that changes section from outside the sidebar itself
+  // (e.g. downloadSelected jumping to Downloads) must go through here too, or
+  // the sidebar visibly disagrees with what's on screen.
+  auto setSection = [&](Section s) {
+    state.section = s;
+    const auto it = std::find(kSidebarSections.begin(), kSidebarSections.end(), s);
+    if (it != kSidebarSections.end()) state.sidebarCursor = static_cast<int>(it - kSidebarSections.begin());
+  };
+
   auto refreshVisible = [&] {
+    const Category cat = sectionToCategory(state.section).value_or(Category::All);
     std::vector<TorrentResult> byCategory;
     for (const auto& r : state.results) {
       const auto it = sources.find(r.source);
-      const bool inCategory = it == sources.end() || sourceInCategory(it->second, state.category);
+      const bool inCategory = it == sources.end() || sourceInCategory(it->second, cat);
       if (inCategory) byCategory.push_back(r);
     }
     auto filtered = filterResults(byCategory, state.hideDead, sources);
@@ -113,13 +208,20 @@ int main() {
       state.selectedResult = std::max(0, static_cast<int>(state.resultLabels.size()) - 1);
     }
   };
-  aggregator.onResultsChanged = refreshVisible;
-
-  auto cycleCategory = [&](int delta) {
-    constexpr int kCategoryCount = 5;  // All, Games, Movies, TV, Anime
-    const int idx = static_cast<int>(state.category);
-    state.category = static_cast<Category>(((idx + delta) % kCategoryCount + kCategoryCount) % kCategoryCount);
+  aggregator.onResultsChanged = [&](bool hadNoResultsBefore) {
     refreshVisible();
+    if (hadNoResultsBefore && !state.results.empty() && state.view == View::Browser &&
+        state.region == Region::Content) {
+      state.focusedIndex = kFocusResults;
+    }
+  };
+
+  auto totalDownloadsRows = [&] { return static_cast<int>(state.items.size() + state.history.size()); };
+  auto seedFor = [&](const std::string& id) -> std::optional<SeedItem> {
+    for (const auto& s : state.seeds) {
+      if (s.id == id) return s;
+    }
+    return std::nullopt;
   };

   auto downloadSelected = [&] {
@@ -134,11 +236,19 @@ int main() {
     const std::string dir = config.downloadDir;
     engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
     state.notice = "queued: " + stripControl(r.name);
+    setSection(Section::Downloads);
+    syncFocus();
   };

+  // --- Search box + results (shared by Splash and the browsing sections) --
+
   InputOption searchOptions;
   searchOptions.multiline = false;
-  searchOptions.on_enter = [&] { aggregator.search(state.query); };
+  searchOptions.on_enter = [&] {
+    aggregator.search(state.query);
+    state.view = View::Browser;
+    syncFocus();
+  };
   Component searchInput = Input(&state.query, "search torrents (all sources)...", searchOptions);

   MenuOption menuOptions = MenuOption::Vertical();
@@ -154,81 +264,514 @@ int main() {
       refreshVisible();
       return true;
     }
-    if (event == Event::Character('h')) {
+    if (event == Event::Character('z')) {
       state.hideDead = !state.hideDead;
       refreshVisible();
       return true;
     }
-    if (event == Event::ArrowLeft) {
-      cycleCategory(-1);
+    return false;
+  });
+
+  Component spinner = MakeSpinner([&] { return state.searching; });
+
+  // --- Downloads section -----------------------------------------------
+
+  Component downloadsBase = Renderer([&]() -> Element {
+    const bool focused = state.region == Region::Content && state.section == Section::Downloads;
+    const int total = totalDownloadsRows();
+    if (total == 0) {
+      return text("No downloads yet. Find something and press d to grab it.") | dim;
+    }
+    Elements rows;
+    for (std::size_t i = 0; i < state.items.size(); ++i) {
+      const auto& it = state.items[i];
+      const bool here = focused && static_cast<int>(i) == state.downloadsCursor;
+      Color statusColor = palette::accent;
+      std::string statusIcon = icon::down;
+      if (it.status == DownloadStatus::Failed) {
+        statusColor = palette::bad;
+        statusIcon = icon::error;
+      } else if (it.status == DownloadStatus::Paused || it.status == DownloadStatus::Queued) {
+        statusColor = palette::paused;
+        statusIcon = it.status == DownloadStatus::Paused ? icon::pause : icon::pending;
+      }
+      std::string right;
+      if (it.status == DownloadStatus::Downloading) {
+        right = std::to_string(it.progress) + "%  " + formatBytes(it.speed) + "/s  peers=" + std::to_string(it.peers);
+      } else if (it.status == DownloadStatus::Paused) {
+        right = "paused  " + std::to_string(it.progress) + "%";
+      } else if (it.status == DownloadStatus::Queued) {
+        right = "queued";
+      } else {
+        right = it.error.value_or("failed");
+      }
+      Element row1 = hbox({
+          text(here ? icon::pointer : " ") | color(palette::accent),
+          text(" "),
+          text(statusIcon) | color(statusColor),
+          text(" "),
+          text(stripControl(it.name).substr(0, 40)) | (here ? bold : dim) | flex,
+          text("  "),
+          text(right) | (it.status == DownloadStatus::Failed ? color(palette::bad) : dim),
+      });
+      Element row2 = hbox({text("    "), gauge(static_cast<float>(it.progress) / 100.0f) | flex});
+      rows.push_back(vbox({row1, row2}));
+    }
+    if (!state.history.empty()) {
+      rows.push_back(text("Recently downloaded (" + std::to_string(state.history.size()) + ")") | dim);
+      for (std::size_t i = 0; i < state.history.size(); ++i) {
+        const auto& h = state.history[i];
+        const bool here = focused && static_cast<int>(state.items.size() + i) == state.downloadsCursor;
+        rows.push_back(hbox({
+            text(here ? icon::pointer : " ") | color(palette::accent),
+            text(" "),
+            text(icon::done) | color(palette::good),
+            text(" "),
+            text(stripControl(h.name).substr(0, 40)) | (here ? bold : dim) | flex,
+            text("  "),
+            text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
+        }));
+      }
+    }
+    return vbox(std::move(rows));
+  });
+
+  Component downloadsComponent = CatchEvent(downloadsBase, [&](Event event) -> bool {
+    if (!(state.region == Region::Content && state.section == Section::Downloads)) return false;
+    const int total = totalDownloadsRows();
+    if (total == 0) return false;
+    const bool inActive = state.downloadsCursor < static_cast<int>(state.items.size());
+
+    if (event == Event::ArrowUp || event == Event::Character('k')) {
+      state.downloadsCursor = wrapStep(state.downloadsCursor, -1, total);
+      return true;
+    }
+    if (event == Event::ArrowDown || event == Event::Character('j')) {
+      state.downloadsCursor = wrapStep(state.downloadsCursor, 1, total);
       return true;
     }
-    if (event == Event::ArrowRight) {
-      cycleCategory(1);
+    if (event == Event::Character('f')) {
+      engine.post([](DownloadQueue& q) { q.retryFailed(); });
       return true;
     }
+    if (event == Event::Character('e')) {
+      const std::string dir = inActive ? state.items[static_cast<std::size_t>(state.downloadsCursor)].dir
+                                        : state.history[static_cast<std::size_t>(state.downloadsCursor) -
+                                                         state.items.size()]
+                                              .dir;
+      if (!openFolder(dir)) state.notice = "Couldn't open folder: " + dir;
+      return true;
+    }
+    if (inActive) {
+      const std::string id = state.items[static_cast<std::size_t>(state.downloadsCursor)].id;
+      if (event == Event::Character('c')) {
+        engine.post([id](DownloadQueue& q) { q.cancel(id); });
+        return true;
+      }
+      if (event == Event::Character('p')) {
+        engine.post([id](DownloadQueue& q) { q.togglePause(id); });
+        return true;
+      }
+    } else {
+      const HistoryItem h = state.history[static_cast<std::size_t>(state.downloadsCursor) - state.items.size()];
+      if (event == Event::Character('d') || event == Event::Return) {
+        AddInput input;
+        input.id = h.id;
+        input.name = h.name;
+        input.magnet = h.magnet;
+        input.source = h.source;
+        input.sizeBytes = h.sizeBytes;
+        const std::string dir = config.downloadDir;
+        engine.post([input, dir](DownloadQueue& q) { q.add(input, dir); });
+        state.notice = "Added: " + stripControl(h.name);
+        return true;
+      }
+      if (event == Event::Character('c')) {
+        const std::string id = h.id;
+        engine.post([id](DownloadQueue& q) { q.removeHistory(id); });
+        return true;
+      }
+    }
     return false;
   });

-  Component spinner = MakeSpinner([&] { return state.searching; });
+  // --- Seeding section ----------------------------------------------------

-  Component mainContainer =
-      Container::Vertical({searchInput, resultsWithKeys, spinner}, &state.focusedIndex);
-
-  Component root = Renderer(mainContainer, [&] {
-    // Results stream in as sources finish, so the list (once non-empty) is
-    // shown *alongside* the in-progress indicator, not hidden behind it --
-    // matching useConcurrentSearch.ts's incremental-results behavior rather
-    // than an all-or-nothing loading state.
-    Elements resultsSection;
-    resultsSection.push_back(text(filterBarText(state)) | dim);
-    if (state.searching) {
-      resultsSection.push_back(hbox({spinner->Render(), text(" " + sourcesStatusLine(state))}));
-    } else {
-      resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
+  Component seedingBase = Renderer([&]() -> Element {
+    const bool focused = state.region == Region::Content && state.section == Section::Seeding;
+    if (state.history.empty()) {
+      return text("Nothing here yet. Downloads start seeding automatically when they finish.") | dim;
     }
-    if (state.resultLabels.empty()) {
-      std::string hint = "no results yet -- type a query and press Enter";
-      if (state.searching) hint = "searching...";
-      else if (!state.results.empty()) hint = "no results in this category/filter";
-      resultsSection.push_back(text(hint) | dim);
+    Elements rows;
+    for (std::size_t i = 0; i < state.history.size(); ++i) {
+      const auto& h = state.history[i];
+      const bool here = focused && static_cast<int>(i) == state.seedingCursor;
+      const auto seed = seedFor(h.id);
+      std::string statusText = "ready";
+      Color statusColor = palette::alt;
+      bool dimIt = true;
+      if (seed) {
+        if (seed->status == SeedStatus::Seeding) {
+          statusText = std::string(icon::up) + formatBytes(seed->uploadSpeed) + "/s peers=" +
+                       std::to_string(seed->peers);
+          statusColor = palette::good;
+          dimIt = false;
+        } else if (seed->status == SeedStatus::Paused) {
+          statusText = "paused";
+        } else {
+          statusText = "file gone";
+          statusColor = palette::warn;
+          dimIt = false;
+        }
+      }
+      Element statusEl = text(statusText) | color(statusColor);
+      if (dimIt) statusEl = statusEl | dim;
+      rows.push_back(hbox({
+          text(here ? icon::pointer : " ") | color(palette::accent),
+          text(" "),
+          text(stripControl(h.name).substr(0, 40)) | (here ? bold : dim) | flex,
+          text("  "),
+          text(h.sizeBytes > 0 ? formatBytes(h.sizeBytes) : "-") | dim,
+          text("  "),
+          statusEl,
+      }));
+    }
+    return vbox(std::move(rows));
+  });
+
+  Component seedingComponent = CatchEvent(seedingBase, [&](Event event) -> bool {
+    if (!(state.region == Region::Content && state.section == Section::Seeding)) return false;
+    const int total = static_cast<int>(state.history.size());
+    if (total == 0) return false;
+
+    if (event == Event::ArrowUp || event == Event::Character('k')) {
+      state.seedingCursor = wrapStep(state.seedingCursor, -1, total);
+      return true;
+    }
+    if (event == Event::ArrowDown || event == Event::Character('j')) {
+      state.seedingCursor = wrapStep(state.seedingCursor, 1, total);
+      return true;
+    }
+    const HistoryItem h = state.history[static_cast<std::size_t>(state.seedingCursor)];
+    if (event == Event::Character('p')) {
+      engine.post([h](DownloadQueue& q) { q.toggleSeeding(h); });
+      return true;
+    }
+    if (event == Event::Character('c')) {
+      const std::string id = h.id;
+      engine.post([id](DownloadQueue& q) { q.removeHistory(id); });
+      return true;
+    }
+    if (event == Event::Character('e')) {
+      if (!openFolder(h.dir)) state.notice = "Couldn't open folder: " + h.dir;
+      return true;
+    }
+    return false;
+  });
+
+  // --- Modal overlays: folder prompt, trackers prompt, help --------------
+  //
+  // These become ordinary children of mainContainer (added after it's
+  // constructed, below), routed to via the same focusedIndex selector as
+  // everything else -- not a separate Container::Tab. An earlier version
+  // used Tab for this and the close/cancel keys silently did nothing: Tab's
+  // event routing did not reliably reach a child with no focusable
+  // descendant of its own (a bare Renderer+CatchEvent, as help's dismiss
+  // handler is). Container::Vertical with an explicit selector is the
+  // mechanism already proven to route events correctly by index throughout
+  // this file, so the modals reuse it instead of a second, less-well-
+  // understood primitive. Declared here (before mainRenderer) since
+  // mainRenderer's Element needs to call their ->Render() directly when a
+  // modal is showing.
+
+  InputOption folderOptions;
+  folderOptions.multiline = false;
+  folderOptions.on_enter = [&] {
+    state.editingFolder = false;
+    syncFocus();
+    const std::string dir = normalizeDownloadDir(state.folderPromptText, homeDir());
+    if (dir.empty() || dir == config.downloadDir) return;
+    std::error_code ec;
+    std::filesystem::create_directories(dir, ec);
+    if (ec) {
+      state.notice = "Couldn't use folder: " + dir;
+      return;
+    }
+    config.downloadDir = dir;
+    saveConfig(config);
+    state.notice = "Download folder: " + dir;
+  };
+  Component folderInput = Input(&state.folderPromptText, "~/Downloads/torlink", folderOptions);
+  folderInput = CatchEvent(folderInput, [&](Event event) {
+    if (event == Event::Escape) {
+      state.editingFolder = false;
+      syncFocus();
+      return true;
+    }
+    return false;
+  });
+
+  InputOption trackersOptions;
+  trackersOptions.multiline = false;
+  trackersOptions.on_enter = [&] {
+    state.editingTrackers = false;
+    syncFocus();
+    const auto list = parseTrackers(state.trackersPromptText);
+    config.trackers = list;
+    saveConfig(config);
+    engine.post([list](DownloadQueue& q) { q.setTrackers(list); });
+    state.notice = list.empty() ? "Cleared extra trackers."
+                                 : "Saved " + std::to_string(list.size()) + " tracker(s).";
+  };
+  Component trackersInput =
+      Input(&state.trackersPromptText, "udp://tracker.example:1337/announce, https://...", trackersOptions);
+  trackersInput = CatchEvent(trackersInput, [&](Event event) {
+    if (event == Event::Escape) {
+      state.editingTrackers = false;
+      syncFocus();
+      return true;
+    }
+    return false;
+  });
+
+  // No visible content of its own -- the help overlay is rendered inline by
+  // mainRenderer when state.showHelp is set. This just needs to be a focus
+  // target that swallows the next keypress to close it.
+  Component helpDismiss = Renderer([] { return text(""); });
+  helpDismiss = CatchEvent(helpDismiss, [&](Event event) {
+    // Event::Custom is EngineThread's periodic redraw signal (posted every
+    // engine tick, ~500ms), not a real keypress -- without excluding it,
+    // help closes itself within one tick of opening, since "any event"
+    // otherwise includes synthetic ones the app posts to itself.
+    if (event == Event::Custom) return false;
+    state.showHelp = false;
+    syncFocus();
+    return true;
+  });
+
+  // --- Sidebar --------------------------------------------------------
+
+  MenuOption sidebarOptions = MenuOption::Vertical();
+  sidebarOptions.on_change = [&] {
+    if (state.sidebarCursor < 0 || state.sidebarCursor >= static_cast<int>(kSidebarSections.size())) return;
+    state.section = kSidebarSections[static_cast<std::size_t>(state.sidebarCursor)];
+    refreshVisible();
+  };
+  sidebarOptions.on_enter = [&] {
+    state.region = Region::Content;
+    syncFocus();
+  };
+  Component sidebarMenu = Menu(&state.sidebarLabels, &state.sidebarCursor, sidebarOptions);
+
+  // --- Top-level composition -------------------------------------------
+
+  Component mainContainer = Container::Vertical(
+      {sidebarMenu, searchInput, resultsWithKeys, downloadsComponent, seedingComponent, spinner},
+      &state.focusedIndex);
+
+  Component mainRenderer = Renderer(mainContainer, [&] {
+    int activeCount = 0;
+    for (const auto& it : state.items) {
+      if (it.status == DownloadStatus::Downloading) activeCount++;
+    }
+    int seedingCount = 0;
+    for (const auto& s : state.seeds) {
+      if (s.status == SeedStatus::Seeding) seedingCount++;
+    }
+    state.sidebarLabels = {
+        "All",
+        "Games",
+        "Movies",
+        "TV",
+        "Anime",
+        activeCount > 0 ? "Downloads (" + std::to_string(activeCount) + ")" : "Downloads",
+        seedingCount > 0 ? "Seeding (" + std::to_string(seedingCount) + ")" : "Seeding",
+    };
+
+    // Sets the terminal tab title, mirroring TabTitle.tsx; a side-effect
+    // write interleaved with FTXUI's own frame output, same as the original
+    // did inside Ink's render cycle.
+    std::cout << "\x1b]0;torlinkc" << (activeCount > 0 ? " (" + std::to_string(activeCount) + ")" : "") << "\x07";
+
+    if (state.showHelp) return renderHelpOverlay();
+    if (state.editingFolder) {
+      return vbox({
+          renderLogo(),
+          separator(),
+          window(text("default download folder"), folderInput->Render()),
+          text("enter: save   esc: cancel") | dim,
+      });
+    }
+    if (state.editingTrackers) {
+      return vbox({
+          renderLogo(),
+          separator(),
+          window(text("extra trackers"), vbox({
+                                              text(trackersStatus(config.trackers, state.trackersPromptText)) | dim,
+                                              trackersInput->Render(),
+                                          })),
+          text("enter: save   esc: cancel") | dim,
+      });
+    }
+
+    if (state.view == View::Splash) {
+      const std::string categories = "games  " + std::string(icon::dot) + "  movies  " + icon::dot + "  tv  " +
+                                      icon::dot + "  anime";
+      return vbox({
+                 filler(),
+                 renderLogo() | center,
+                 text("") | center,
+                 text("A curated, terminal-native torrent downloader.") | color(palette::text) | center,
+                 text(categories) | dim | center,
+                 text("") | center,
+                 searchInput->Render() | size(WIDTH, EQUAL, 56) | center,
+                 text("") | center,
+                 hbox({text("enter") | color(palette::alt), text(" search   ") | dim, text("tab") | color(palette::alt),
+                       text(" browse   ") | dim, text("esc") | color(palette::alt), text(" quit") | dim}) |
+                     center,
+                 filler(),
+             }) |
+             flex;
+    }
+
+    Element content;
+    if (state.section == Section::Downloads) {
+      content = window(text("Downloads"), downloadsComponent->Render());
+    } else if (state.section == Section::Seeding) {
+      content = window(text("Seeding"), seedingComponent->Render());
     } else {
-      resultsSection.push_back(resultsMenu->Render() | frame | flex);
+      Elements resultsSection;
+      resultsSection.push_back(text(filterBarText(state)) | dim);
+      if (state.searching) {
+        resultsSection.push_back(hbox({spinner->Render(), text(" " + sourcesStatusLine(state))}));
+      } else {
+        resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
+      }
+      if (state.resultLabels.empty()) {
+        std::string hint = "no results yet -- type a query and press Enter";
+        if (state.searching) hint = "searching...";
+        else if (!state.results.empty()) hint = "no results in this category/filter";
+        resultsSection.push_back(text(hint) | dim);
+      } else {
+        resultsSection.push_back(resultsMenu->Render() | frame | flex);
+      }
+      content = vbox({
+          window(text("Search"), searchInput->Render()),
+          window(text(sectionLabel(state.section) + " results"), vbox(std::move(resultsSection)) | flex) | flex,
+      });
     }

-    Elements bottom;
-    if (!state.notice.empty()) bottom.push_back(text(state.notice) | dim);
+    Elements top = {renderLogo()};
+    if (!state.notice.empty()) top.push_back(filler());
+    if (!state.notice.empty()) top.push_back(text(state.notice) | color(palette::good));

     return vbox({
-               text("torlinkc") | bold,
+               hbox(std::move(top)),
                separator(),
-               window(text("Search"), searchInput->Render()),
-               window(text("Results"), vbox(std::move(resultsSection)) | flex) | flex,
-               window(text("Downloads"), renderDownloads(state)),
+               hbox({
+                   sidebarMenu->Render() | size(WIDTH, EQUAL, 16),
+                   separator(),
+                   content | flex,
+               }) | flex,
                separator(),
-               vbox(std::move(bottom)),
-               text("Enter: search/download   d: download   s: sort   h: hide dead   "
-                    "←/→: category   Esc: quit") |
-                   dim,
+               renderFooter(state),
            }) |
            flex;
   });

-  // Escape quits from anywhere (checked before the focused child sees the
-  // event), matching the original App.tsx's global-keybinding-first model.
-  // Regular text input (including 'q'/'s'/'h'/'d', while the search box has
-  // focus) is left alone so typing a query never accidentally triggers a
-  // results-list shortcut.
-  root = CatchEvent(root, [&](Event event) {
+  Component mainWithGlobalKeys = CatchEvent(mainRenderer, [&](Event event) -> bool {
+    // The prompt/help component at focusedIndex owns all input while shown
+    // (matching the original's `if (editingFolder || editingTrackers ||
+    // pendingDownload) return;` at the very top of its global handler) --
+    // otherwise, e.g., this handler's own Escape case would fire before the
+    // folder prompt's Input ever saw the key press meant to cancel it.
+    if (state.showHelp || state.editingFolder || state.editingTrackers) return false;
+
+    const bool textEditing = state.focusedIndex == kFocusSearch;
+
+    if (!textEditing) {
+      if (event == Event::Character('q')) {
+        screen.Exit();
+        return true;
+      }
+      if (event == Event::Character('?')) {
+        state.showHelp = true;
+        syncFocus();
+        return true;
+      }
+      if (event == Event::Character('o')) {
+        state.folderPromptText = config.downloadDir;
+        state.editingFolder = true;
+        syncFocus();
+        return true;
+      }
+      if (event == Event::Character('t')) {
+        state.trackersPromptText = formatTrackers(config.trackers);
+        state.editingTrackers = true;
+        syncFocus();
+        return true;
+      }
+    }
+
+    if (event == Event::Tab) {
+      if (state.view == View::Splash) {
+        // Matches the original's Splash "tab browse" hint: give up on typing
+        // a query and browse everything, the same as pressing Enter on an
+        // empty search box would. Without this, Tab would leave focus on an
+        // empty, unpopulated results list, and further keystrokes would be
+        // silently swallowed as global shortcuts instead of reaching a text
+        // field or a populated list.
+        aggregator.search(state.query);
+        state.view = View::Browser;
+      } else {
+        state.region = state.region == Region::Sidebar ? Region::Content : Region::Sidebar;
+      }
+      syncFocus();
+      return true;
+    }
+    if ((event == Event::ArrowRight || (!textEditing && event == Event::Character('l'))) &&
+        state.view == View::Browser && state.region == Region::Sidebar) {
+      state.region = Region::Content;
+      syncFocus();
+      return true;
+    }
+    if ((event == Event::ArrowLeft || (!textEditing && event == Event::Character('h'))) &&
+        state.view == View::Browser && state.region == Region::Content) {
+      state.region = Region::Sidebar;
+      syncFocus();
+      return true;
+    }
     if (event == Event::Escape) {
-      screen.Exit();
+      if (state.view == View::Splash) {
+        screen.Exit();
+        return true;
+      }
+      if (state.region == Region::Content) {
+        state.region = Region::Sidebar;
+        syncFocus();
+        return true;
+      }
+      state.view = View::Splash;
+      syncFocus();
       return true;
     }
     return false;
   });

+  // Modal overlays (folder prompt, trackers prompt, help) are ordinary
+  // children of mainContainer, added below -- see the comment where they're
+  // defined for why not a separate Container::Tab.
+  mainContainer->Add(folderInput);
+  mainContainer->Add(trackersInput);
+  mainContainer->Add(helpDismiss);
+
+  // AppState::focusedIndex defaults to 0 (the sidebar), not the search box
+  // Splash needs -- without this, the first keystrokes on launch go nowhere
+  // useful (the sidebar Menu ignores character input).
+  syncFocus();
+
   engine.start(config);
-  screen.Loop(root);
+  screen.Loop(mainWithGlobalKeys);
   engine.stop();

   return 0;
diff --git a/include/torlinkc/config/folder.hpp b/include/torlinkc/config/folder.hpp
new file mode 100644
index 0000000..f78d252
--- /dev/null
+++ b/include/torlinkc/config/folder.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc {
+
+// Expands a leading ~ (and ~\, for paths pasted from Windows) against `home`.
+// ~bob isn't us, so it's left alone. Ported from config/folder.ts.
+std::string expandHome(const std::string& input, const std::string& home);
+
+// Typed input -> a path suitable for creating a directory. Blank returns ""
+// (caller: leave it be).
+std::string normalizeDownloadDir(const std::string& input, const std::string& home);
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/config/trackers.hpp b/include/torlinkc/config/trackers.hpp
new file mode 100644
index 0000000..de32fef
--- /dev/null
+++ b/include/torlinkc/config/trackers.hpp
@@ -0,0 +1,19 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+namespace torlinkc {
+
+// Splits on commas/whitespace, validates scheme (udp/http(s)/ws(s)), dedupes.
+// Ported from config/trackers.ts.
+std::vector<std::string> parseTrackers(const std::string& input);
+
+std::string formatTrackers(const std::vector<std::string>& trackers);
+
+// One dim status line for the trackers prompt: what is saved now, what the
+// current field text will save, and how many typed tokens get dropped (bad
+// scheme or duplicate).
+std::string trackersStatus(const std::vector<std::string>& saved, const std::string& fieldText);
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/ui/app_state.hpp b/include/torlinkc/ui/app_state.hpp
index 91c41b2..5e55be1 100644
--- a/include/torlinkc/ui/app_state.hpp
+++ b/include/torlinkc/ui/app_state.hpp
@@ -5,6 +5,7 @@
 #include <unordered_map>
 #include <vector>

+#include "torlinkc/engine/history.hpp"
 #include "torlinkc/engine/types.hpp"
 #include "torlinkc/sources/registry.hpp"
 #include "torlinkc/sources/types.hpp"
@@ -21,6 +22,23 @@ struct SourceState {
   int count = 0;
 };

+// Ported from store.ts.
+enum class View { Splash, Browser };
+
+// Merges store.ts's Category ("all"/"games"/"movies"/"tv"/"anime") with its
+// Section ("downloads"/"seeding") into one enum, since the C++ port has no
+// separate synced category+section pair to keep in step.
+enum class Section { All, Games, Movies, TV, Anime, Downloads, Seeding };
+
+enum class Region { Sidebar, Content };
+
+enum class DownloadFocus { Downloading, Paused, Failed, Recent };
+enum class SeedFocus { Seeding, Paused, Missing, Idle };
+
+// nullopt = the given category doesn't filter sources (Downloads/Seeding).
+std::optional<Category> sectionToCategory(Section s);
+std::string sectionLabel(Section s);
+
 // The single source of truth for the TUI, mutated only on the UI thread --
 // either directly inside an FTXUI event handler, or via a closure a
 // background thread posts through ftxui::ScreenInteractive::Post(). Ported
@@ -30,29 +48,45 @@ struct SourceState {
 // No internal locking: that invariant (UI-thread-only mutation) is what
 // makes locking unnecessary, not something this struct enforces itself.
 struct AppState {
-  // Which child of the top-level Container::Vertical has focus: 0 = search
-  // box, 1 = results list (see main.cpp, which owns that ordering). Tab/
-  // Shift-Tab update this the normal way; SearchAggregator also nudges it to
-  // 1 when results first arrive, so a completed search is immediately
-  // navigable without the user having to Tab there themselves -- otherwise a
-  // hotkey like 'd' typed right after Enter lands in the still-focused
-  // search box as a literal character instead of reaching the results list.
+  // Navigation (ported from store.ts's view/section/region/*Focus).
+  View view = View::Splash;
+  Section section = Section::All;
+  Region region = Region::Content;
+  std::optional<DownloadFocus> downloadFocus;
+  std::optional<SeedFocus> seedFocus;
+
+  // Index of the currently focused child in main.cpp's top-level
+  // Container::Vertical -- see kFocus* constants in main.cpp. Kept in sync
+  // with view/section/region by main.cpp's syncFocus(), which also runs
+  // whenever SearchAggregator lands the first results of a search (so a
+  // hotkey pressed right after Enter reaches the results list, not the
+  // still-focused search box).
   int focusedIndex = 0;
+  int sidebarCursor = 0;
+  int downloadsCursor = 0;
+  int seedingCursor = 0;
+  std::vector<std::string> sidebarLabels;  // rebuilt each render; badges depend on live counts
+
+  bool showHelp = false;
+  bool editingFolder = false;
+  bool editingTrackers = false;
+  std::string folderPromptText;
+  std::string trackersPromptText;

   // Search, across all sources (Phase 3's SearchAggregator).
   std::string query;
-  std::vector<TorrentResult> results;         // deduped, default-ordered, unfiltered -- SearchAggregator's output
+  std::vector<TorrentResult> results;  // deduped, default-ordered, unfiltered -- SearchAggregator's output
   std::unordered_map<std::string, SourceState> perSource;
   int doneSources = 0;
   int totalSources = 0;
   bool searching = false;

-  // Display: category/hideDead/sort applied to `results`, recomputed into
-  // these by main.cpp's refreshVisible() whenever `results` or any of the
-  // three change. Kept as persistent AppState fields (not computed fresh
-  // inside Render()) because FTXUI's Menu binds to resultLabels by address
-  // at construction time -- see the class comment on that invariant.
-  Category category = Category::All;
+  // Display: section-as-category/hideDead/sort applied to `results`,
+  // recomputed into these by main.cpp's refreshVisible() whenever `results`
+  // or any of the three change. Kept as persistent AppState fields (not
+  // computed fresh inside Render()) because FTXUI's Menu binds to
+  // resultLabels by address at construction time -- see the class comment on
+  // that invariant.
   bool hideDead = false;
   Sort sort;
   std::vector<TorrentResult> visibleResults;
@@ -61,6 +95,7 @@ struct AppState {

   // Downloads / seeds, replaced wholesale each engine-thread tick.
   std::vector<QueueItem> items;
+  std::vector<HistoryItem> history;
   std::vector<SeedItem> seeds;

   std::string notice;
diff --git a/include/torlinkc/ui/keymap.hpp b/include/torlinkc/ui/keymap.hpp
new file mode 100644
index 0000000..0915868
--- /dev/null
+++ b/include/torlinkc/ui/keymap.hpp
@@ -0,0 +1,31 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "torlinkc/ui/app_state.hpp"
+
+namespace torlinkc::ui {
+
+struct Hint {
+  std::string keys;
+  std::string label;
+};
+
+struct HelpGroup {
+  std::string title;
+  std::vector<Hint> hints;
+};
+
+// Ported from ui/keymap.ts::HELP_GROUPS, trimmed to the keys this port
+// actually implements (no result-detail view, no clipboard/export actions --
+// see the Phase 4 commit message for what's deferred).
+const std::vector<HelpGroup>& helpGroups();
+
+// The terse contextual hint row along the bottom, ported from
+// ui/keymap.ts::footerHints.
+std::vector<Hint> footerHints(Region region, Section section, std::optional<DownloadFocus> downloadFocus,
+                               std::optional<SeedFocus> seedFocus);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/logo.hpp b/include/torlinkc/ui/logo.hpp
new file mode 100644
index 0000000..05311c1
--- /dev/null
+++ b/include/torlinkc/ui/logo.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <ftxui/dom/elements.hpp>
+
+namespace torlinkc::ui {
+
+inline constexpr int kLogoWidth = 27;
+
+// The torlinkc wordmark, rendered with a horizontal accent->bright gradient.
+// Ported from ui/logo.ts + ui/components/Logo.tsx (per-cell sheen simplified
+// to a static gradient -- see the Phase 4 commit message).
+ftxui::Element renderLogo();
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/move.hpp b/include/torlinkc/ui/move.hpp
new file mode 100644
index 0000000..15aece2
--- /dev/null
+++ b/include/torlinkc/ui/move.hpp
@@ -0,0 +1,12 @@
+#pragma once
+
+namespace torlinkc::ui {
+
+// Modulo-wraparound stepping for cursor movement. Ported from ui/move.ts.
+int wrapStep(int current, int delta, int length);
+
+// Scrolling-viewport start index that keeps `cursor` centered, clamped to
+// bounds. Ported from ui/move.ts.
+int windowStart(int cursor, int total, int height);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/search_aggregator.hpp b/include/torlinkc/ui/search_aggregator.hpp
index d619a64..e92c62f 100644
--- a/include/torlinkc/ui/search_aggregator.hpp
+++ b/include/torlinkc/ui/search_aggregator.hpp
@@ -43,9 +43,11 @@ class SearchAggregator {

   // Called on the UI thread at the end of every flush, after state_.results
   // is updated -- main.cpp hooks this to recompute visibleResults/
-  // resultLabels (category/hideDead/sort applied), which SearchAggregator
-  // itself has no opinion on.
-  std::function<void()> onResultsChanged;
+  // resultLabels (category/hideDead/sort applied) and to decide whether to
+  // jump focus to the results list, neither of which SearchAggregator has an
+  // opinion on. The argument is whether state_.results was empty just before
+  // this flush.
+  std::function<void(bool hadNoResultsBefore)> onResultsChanged;

  private:
   void runSource(const Source& source, const std::string& query, std::stop_token stopToken, int generation);
diff --git a/include/torlinkc/ui/theme.hpp b/include/torlinkc/ui/theme.hpp
new file mode 100644
index 0000000..84e19b7
--- /dev/null
+++ b/include/torlinkc/ui/theme.hpp
@@ -0,0 +1,47 @@
+#pragma once
+
+#include <string>
+
+#include <ftxui/screen/color.hpp>
+
+namespace torlinkc::ui {
+
+// Ported from ui/theme.ts. FTXUI Colors instead of hex strings, but the same
+// palette.
+namespace palette {
+inline const ftxui::Color accent = ftxui::Color::RGB(167, 139, 250);
+inline const ftxui::Color text = ftxui::Color::RGB(233, 228, 245);
+inline const ftxui::Color alt = ftxui::Color::RGB(185, 167, 230);
+inline const ftxui::Color good = ftxui::Color::RGB(134, 214, 162);
+inline const ftxui::Color warn = ftxui::Color::RGB(240, 197, 96);
+inline const ftxui::Color bad = ftxui::Color::RGB(238, 125, 146);
+inline const ftxui::Color bright = ftxui::Color::RGB(216, 180, 254);
+inline const ftxui::Color rule = ftxui::Color::RGB(107, 101, 119);
+inline const ftxui::Color paused = ftxui::Color::RGB(124, 119, 133);
+}  // namespace palette
+
+namespace icon {
+inline constexpr const char* done = "✓";
+inline constexpr const char* error = "✗";
+inline constexpr const char* pending = "·";
+inline constexpr const char* pointer = "❯";
+inline constexpr const char* dot = "·";
+inline constexpr const char* warn = "⚠";
+inline constexpr const char* bar = "▌";
+inline constexpr const char* down = "↓";
+inline constexpr const char* up = "↑";
+inline constexpr const char* peer = "•";
+inline constexpr const char* pause = "⏸";
+}  // namespace icon
+
+struct SourceStyle {
+  std::string tag;
+  ftxui::Color color;
+};
+
+// Tolerant lookup: falls back to a neutral tag rather than crashing on an
+// absent/unknown source id (a pasted magnet, or a removed source persisted
+// in old history).
+SourceStyle sourceStyle(const std::string& id);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/util/open_folder.hpp b/include/torlinkc/util/open_folder.hpp
new file mode 100644
index 0000000..a08ad4a
--- /dev/null
+++ b/include/torlinkc/util/open_folder.hpp
@@ -0,0 +1,13 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc {
+
+// Opens `dir` in the platform file manager (xdg-open/gio on Linux, open on
+// macOS). Never throws; false means the caller should tell the user it
+// didn't work. Ported from util/openFolder.ts (Windows path dropped per the
+// Linux/macOS-first scope decision).
+bool openFolder(const std::string& dir);
+
+}  // namespace torlinkc
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index a4098fe..e59f7de 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -1,6 +1,8 @@
 add_library(torlinkc_core STATIC
   config/config.cpp
+  config/folder.cpp
   config/paths.cpp
+  config/trackers.cpp
   engine/bootguard.cpp
   engine/delete_data.cpp
   engine/history.cpp
@@ -25,6 +27,7 @@ add_library(torlinkc_core STATIC
   util/date_parse.cpp
   util/format.cpp
   util/net.cpp
+  util/open_folder.cpp
   util/url_encode.cpp
 )

@@ -40,12 +43,17 @@ target_link_libraries(torlinkc_core PUBLIC
 # UI-agnostic (Phase 1's console harness and Phase 5's daemon modes link
 # torlinkc_core without ever pulling in FTXUI).
 add_library(torlinkc_ui STATIC
+  ui/app_state.cpp
   ui/coalescing_notifier.cpp
   ui/engine_thread.cpp
   ui/filter.cpp
+  ui/keymap.cpp
+  ui/logo.cpp
+  ui/move.cpp
   ui/search_aggregator.cpp
   ui/sort.cpp
   ui/spinner.cpp
+  ui/theme.cpp
 )

 target_link_libraries(torlinkc_ui PUBLIC
diff --git a/src/config/folder.cpp b/src/config/folder.cpp
new file mode 100644
index 0000000..ad84dd2
--- /dev/null
+++ b/src/config/folder.cpp
@@ -0,0 +1,31 @@
+#include "torlinkc/config/folder.hpp"
+
+#include <filesystem>
+
+namespace torlinkc {
+
+namespace {
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+}  // namespace
+
+std::string expandHome(const std::string& input, const std::string& home) {
+  const std::string trimmed = trim(input);
+  if (trimmed == "~") return home;
+  if (trimmed.rfind("~/", 0) == 0 || trimmed.rfind("~\\", 0) == 0) {
+    return (std::filesystem::path(home) / trimmed.substr(2)).string();
+  }
+  return trimmed;
+}
+
+std::string normalizeDownloadDir(const std::string& input, const std::string& home) {
+  const std::string expanded = expandHome(input, home);
+  if (expanded.empty()) return "";
+  return std::filesystem::path(expanded).lexically_normal().string();
+}
+
+}  // namespace torlinkc
diff --git a/src/config/trackers.cpp b/src/config/trackers.cpp
new file mode 100644
index 0000000..e8bcef5
--- /dev/null
+++ b/src/config/trackers.cpp
@@ -0,0 +1,81 @@
+#include "torlinkc/config/trackers.hpp"
+
+#include <regex>
+#include <unordered_set>
+
+namespace torlinkc {
+
+namespace {
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::vector<std::string> splitOnCommaOrWhitespace(const std::string& s) {
+  std::vector<std::string> out;
+  std::string cur;
+  for (char c : s) {
+    if (c == ',' || std::isspace(static_cast<unsigned char>(c))) {
+      if (!cur.empty()) out.push_back(cur);
+      cur.clear();
+    } else {
+      cur += c;
+    }
+  }
+  if (!cur.empty()) out.push_back(cur);
+  return out;
+}
+
+bool hasValidScheme(const std::string& url) {
+  static const std::regex kScheme(R"(^(udp|https?|wss?)://)", std::regex::icase);
+  return std::regex_search(url, kScheme);
+}
+
+}  // namespace
+
+std::vector<std::string> parseTrackers(const std::string& input) {
+  std::unordered_set<std::string> seen;
+  std::vector<std::string> out;
+  for (const auto& raw : splitOnCommaOrWhitespace(input)) {
+    const std::string url = trim(raw);
+    if (url.empty() || !hasValidScheme(url)) continue;
+    if (!seen.insert(url).second) continue;
+    out.push_back(url);
+  }
+  return out;
+}
+
+std::string formatTrackers(const std::vector<std::string>& trackers) {
+  std::string out;
+  for (std::size_t i = 0; i < trackers.size(); ++i) {
+    if (i) out += ", ";
+    out += trackers[i];
+  }
+  return out;
+}
+
+std::string trackersStatus(const std::vector<std::string>& saved, const std::string& fieldText) {
+  const auto next = parseTrackers(fieldText);
+  std::size_t tokenCount = 0;
+  for (const auto& t : splitOnCommaOrWhitespace(fieldText)) {
+    if (!trim(t).empty()) tokenCount++;
+  }
+  const std::size_t ignored = tokenCount - next.size();
+  const std::string savedLabel = saved.empty() ? "none saved" : std::to_string(saved.size()) + " saved";
+
+  const bool unchanged = ignored == 0 && next.size() == saved.size() && next == saved;
+  if (unchanged) {
+    return saved.empty() ? "none saved · comma or space separated"
+                          : savedLabel + " · comma or space separated · empty clears";
+  }
+  if (tokenCount == 0) return savedLabel + " → empty clears all";
+
+  std::string line = savedLabel + " → will save " + std::to_string(next.size());
+  if (ignored > 0) line += " · " + std::to_string(ignored) + " ignored";
+  return line;
+}
+
+}  // namespace torlinkc
diff --git a/src/ui/app_state.cpp b/src/ui/app_state.cpp
new file mode 100644
index 0000000..b52c8d4
--- /dev/null
+++ b/src/ui/app_state.cpp
@@ -0,0 +1,44 @@
+#include "torlinkc/ui/app_state.hpp"
+
+namespace torlinkc::ui {
+
+std::optional<Category> sectionToCategory(Section s) {
+  switch (s) {
+    case Section::All:
+      return Category::All;
+    case Section::Games:
+      return Category::Games;
+    case Section::Movies:
+      return Category::Movies;
+    case Section::TV:
+      return Category::TV;
+    case Section::Anime:
+      return Category::Anime;
+    case Section::Downloads:
+    case Section::Seeding:
+      return std::nullopt;
+  }
+  return Category::All;
+}
+
+std::string sectionLabel(Section s) {
+  switch (s) {
+    case Section::All:
+      return "All";
+    case Section::Games:
+      return "Games";
+    case Section::Movies:
+      return "Movies";
+    case Section::TV:
+      return "TV";
+    case Section::Anime:
+      return "Anime";
+    case Section::Downloads:
+      return "Downloads";
+    case Section::Seeding:
+      return "Seeding";
+  }
+  return "All";
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/engine_thread.cpp b/src/ui/engine_thread.cpp
index 83aa951..6120d57 100644
--- a/src/ui/engine_thread.cpp
+++ b/src/ui/engine_thread.cpp
@@ -77,9 +77,11 @@ void EngineThread::run(Config config) {
 void EngineThread::publishSnapshot(DownloadQueue& queue) {
   auto items = queue.getItems();
   auto seeds = queue.getSeeds();
-  screen_.Post([this, items = std::move(items), seeds = std::move(seeds)]() mutable {
+  auto history = queue.getHistory();
+  screen_.Post([this, items = std::move(items), seeds = std::move(seeds), history = std::move(history)]() mutable {
     state_.items = std::move(items);
     state_.seeds = std::move(seeds);
+    state_.history = std::move(history);
   });
   // A bare Post()'d closure updates state but doesn't itself wake FTXUI's
   // main loop to redraw -- only a real Event does. PostEvent(Event::Custom)
diff --git a/src/ui/keymap.cpp b/src/ui/keymap.cpp
new file mode 100644
index 0000000..cbb6f5a
--- /dev/null
+++ b/src/ui/keymap.cpp
@@ -0,0 +1,75 @@
+#include "torlinkc/ui/keymap.hpp"
+
+namespace torlinkc::ui {
+
+const std::vector<HelpGroup>& helpGroups() {
+  static const std::vector<HelpGroup> groups = {
+      {"Navigate",
+       {
+           {"up/down/left/right", "Navigate panes and lists"},
+           {"enter", "Open"},
+           {"tab", "Switch pane"},
+           {"esc", "Back"},
+           {"o", "Default download folder"},
+           {"t", "Extra trackers"},
+           {"q", "Quit"},
+       }},
+      {"Search",
+       {
+           {"enter", "Search / download selected"},
+           {"d", "Download"},
+           {"s", "Sort results"},
+           {"z", "Hide dead torrents"},
+       }},
+      {"Downloads",
+       {
+           {"p", "Pause/resume"},
+           {"c", "Cancel or remove"},
+           {"f", "Retry failed"},
+           {"d", "Download again"},
+           {"e", "Open folder"},
+       }},
+      {"Seeding",
+       {
+           {"p", "Pause/resume"},
+           {"c", "Remove"},
+           {"e", "Open folder"},
+       }},
+  };
+  return groups;
+}
+
+namespace {
+const Hint kNavigate{"up/down/left/right", "Move"};
+const Hint kAlways{"?", "Keys"};
+const Hint kSwitch{"tab", "Switch"};
+const Hint kFolder{"e", "Folder"};
+}  // namespace
+
+std::vector<Hint> footerHints(Region region, Section section, std::optional<DownloadFocus> downloadFocus,
+                               std::optional<SeedFocus> seedFocus) {
+  if (region == Region::Sidebar) {
+    return {kNavigate, {"enter", "Open"}, kSwitch, kAlways, {"q", "Quit"}};
+  }
+  if (section == Section::Seeding) {
+    std::string label = "Resume";
+    if (seedFocus == SeedFocus::Seeding) label = "Pause";
+    else if (seedFocus == SeedFocus::Missing) label = "Retry";
+    return {{"p", label}, {"c", "Remove from list"}, kFolder, kSwitch, kAlways};
+  }
+  if (section == Section::Downloads) {
+    if (downloadFocus == DownloadFocus::Paused) {
+      return {{"p", "Resume"}, {"c", "Cancel"}, kFolder, kSwitch, kAlways};
+    }
+    if (downloadFocus == DownloadFocus::Failed) {
+      return {{"f", "Retry"}, {"c", "Remove"}, kFolder, kSwitch, kAlways};
+    }
+    if (downloadFocus == DownloadFocus::Recent) {
+      return {{"d", "Redownload"}, {"c", "Remove from list"}, kFolder, kSwitch, kAlways};
+    }
+    return {{"p", "Pause"}, {"c", "Cancel"}, kFolder, kSwitch, kAlways};
+  }
+  return {kNavigate, {"d", "Download"}, {"s", "Sort"}, {"z", "Hide dead"}, kSwitch, kAlways};
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/logo.cpp b/src/ui/logo.cpp
new file mode 100644
index 0000000..0fce2e5
--- /dev/null
+++ b/src/ui/logo.cpp
@@ -0,0 +1,61 @@
+#include "torlinkc/ui/logo.hpp"
+
+#include <cmath>
+#include <string>
+#include <vector>
+
+using namespace ftxui;
+
+namespace torlinkc::ui {
+
+namespace {
+
+const std::vector<std::string> kLogoLines = {
+    "      𐓏                     ",
+    " ▀█▀ █▀█ █▀█ █   █ █▄ █ █▄▀",
+    "  █  █▄█ █▀▄ █▄▄ █ █ ▀█ █ █",
+};
+
+// accent (#a78bfa) -> bright (#d8b4fe), matching ui/theme.ts::ACCENT_RAMP.
+constexpr int kAccentR = 167, kAccentG = 139, kAccentB = 250;
+constexpr int kBrightR = 216, kBrightG = 180, kBrightB = 254;
+
+std::vector<std::string> splitUtf8(const std::string& s) {
+  std::vector<std::string> out;
+  std::size_t i = 0;
+  while (i < s.size()) {
+    const unsigned char c = static_cast<unsigned char>(s[i]);
+    std::size_t len = 1;
+    if ((c & 0xE0) == 0xC0) len = 2;
+    else if ((c & 0xF0) == 0xE0) len = 3;
+    else if ((c & 0xF8) == 0xF0) len = 4;
+    len = std::min(len, s.size() - i);
+    out.push_back(s.substr(i, len));
+    i += len;
+  }
+  return out;
+}
+
+Color lerpAccent(float t) {
+  auto lerp = [t](int a, int b) { return static_cast<int>(std::lround(a + (b - a) * t)); };
+  return Color::RGB(lerp(kAccentR, kBrightR), lerp(kAccentG, kBrightG), lerp(kAccentB, kBrightB));
+}
+
+}  // namespace
+
+Element renderLogo() {
+  Elements rows;
+  for (const auto& line : kLogoLines) {
+    const auto glyphs = splitUtf8(line);
+    Elements cells;
+    cells.reserve(glyphs.size());
+    for (std::size_t i = 0; i < glyphs.size(); ++i) {
+      const float t = glyphs.size() <= 1 ? 0.0f : static_cast<float>(i) / static_cast<float>(glyphs.size() - 1);
+      cells.push_back(text(glyphs[i]) | color(lerpAccent(t)));
+    }
+    rows.push_back(hbox(std::move(cells)));
+  }
+  return vbox(std::move(rows)) | bold;
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/move.cpp b/src/ui/move.cpp
new file mode 100644
index 0000000..d994a14
--- /dev/null
+++ b/src/ui/move.cpp
@@ -0,0 +1,18 @@
+#include "torlinkc/ui/move.hpp"
+
+#include <algorithm>
+
+namespace torlinkc::ui {
+
+int wrapStep(int current, int delta, int length) {
+  if (length <= 0) return 0;
+  return ((current + delta) % length + length) % length;
+}
+
+int windowStart(int cursor, int total, int height) {
+  if (total <= height) return 0;
+  const int half = height / 2;
+  return std::max(0, std::min(cursor - half, total - height));
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/search_aggregator.cpp b/src/ui/search_aggregator.cpp
index 2a617da..db8337c 100644
--- a/src/ui/search_aggregator.cpp
+++ b/src/ui/search_aggregator.cpp
@@ -138,11 +138,11 @@ void SearchAggregator::flush() {
     state_.doneSources = doneCopy;
     state_.totalSources = totalCopy;
     state_.searching = doneCopy < totalCopy;
-    if (onResultsChanged) onResultsChanged();
-    // Jump focus to the results list (index 1 in main.cpp's container) the
-    // first time this search produces any results, so a hotkey pressed
-    // right after Enter reaches it instead of the still-focused search box.
-    if (hadNoResultsBefore && !state_.results.empty()) state_.focusedIndex = 1;
+    // main.cpp's onResultsChanged hook recomputes visibleResults/resultLabels
+    // and owns the "jump focus to the results list the first time this
+    // search produces any results" decision -- it knows the container's
+    // focus-index scheme, which SearchAggregator deliberately doesn't.
+    if (onResultsChanged) onResultsChanged(hadNoResultsBefore);
   });
   screen_.PostEvent(ftxui::Event::Custom);
 }
diff --git a/src/ui/theme.cpp b/src/ui/theme.cpp
new file mode 100644
index 0000000..bebf729
--- /dev/null
+++ b/src/ui/theme.cpp
@@ -0,0 +1,24 @@
+#include "torlinkc/ui/theme.hpp"
+
+#include <unordered_map>
+
+namespace torlinkc::ui {
+
+SourceStyle sourceStyle(const std::string& id) {
+  static const std::unordered_map<std::string, SourceStyle> styles = {
+      {"fitgirl", {"FG", palette::accent}},
+      {"yts", {"YTS", palette::good}},
+      {"eztv", {"EZTV", palette::warn}},
+      {"nyaa", {"NYAA", palette::bright}},
+      {"subsplease", {"SUB", palette::alt}},
+      {"tpb-movies", {"TPB", ftxui::Color::RGB(95, 208, 197)}},
+      {"tpb-tv", {"TPB", ftxui::Color::RGB(95, 208, 197)}},
+      {"x1337-movies", {"1337", ftxui::Color::RGB(246, 165, 92)}},
+      {"x1337-tv", {"1337", ftxui::Color::RGB(246, 165, 92)}},
+      {"bittorrented", {"BT", ftxui::Color::RGB(125, 184, 240)}},
+  };
+  if (auto it = styles.find(id); it != styles.end()) return it->second;
+  return SourceStyle{"•", palette::alt};
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/util/open_folder.cpp b/src/util/open_folder.cpp
new file mode 100644
index 0000000..5fde470
--- /dev/null
+++ b/src/util/open_folder.cpp
@@ -0,0 +1,64 @@
+#include "torlinkc/util/open_folder.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <thread>
+
+#include <signal.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+namespace torlinkc {
+
+namespace {
+
+// Forks, execs the first candidate that exists is left to the shell's PATH
+// lookup (execvp), and waits up to ~4s for a clean exit. Never throws.
+bool spawnAndWait(const std::vector<std::vector<std::string>>& candidates) {
+  for (const auto& argvStrings : candidates) {
+    pid_t pid = fork();
+    if (pid < 0) continue;
+    if (pid == 0) {
+      std::vector<char*> argv;
+      for (const auto& s : argvStrings) argv.push_back(const_cast<char*>(s.c_str()));
+      argv.push_back(nullptr);
+      execvp(argv[0], argv.data());
+      _exit(127);  // execvp only returns on failure
+    }
+
+    bool exited = false;
+    int status = 0;
+    for (int i = 0; i < 40; ++i) {  // ~4s in 100ms slices
+      if (waitpid(pid, &status, WNOHANG) == pid) {
+        exited = true;
+        break;
+      }
+      std::this_thread::sleep_for(std::chrono::milliseconds(100));
+    }
+    if (!exited) {
+      kill(pid, SIGKILL);
+      waitpid(pid, nullptr, 0);
+      continue;
+    }
+    if (WIFEXITED(status) && WEXITSTATUS(status) == 0) return true;
+  }
+  return false;
+}
+
+}  // namespace
+
+bool openFolder(const std::string& dir) {
+  // Check the path ourselves first, matching the original's rationale (some
+  // launchers silently open a fallback location for a nonexistent path,
+  // which would look like success).
+  std::error_code ec;
+  if (dir.empty() || !std::filesystem::exists(dir, ec)) return false;
+
+#ifdef __APPLE__
+  return spawnAndWait({{"open", dir}});
+#else
+  return spawnAndWait({{"xdg-open", dir}, {"gio", "open", dir}});
+#endif
+}
+
+}  // namespace torlinkc