foxygit / TransTUI Log in
commit 7dbe67c8c2e9648975cfa2bdc911ba3b08a1ca52
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Thu Jul 30 23:13:12 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Thu Jul 30 23:13:12 2026 +0200

    New
---
 .gitignore                               |    2 +
 Makefile                                 |   49 +
 README.md                                |   49 +
 desktop/transtui-add.desktop             |    9 +
 docs/Architecture.md                     |  113 ++
 docs/Browser-Integration.md              |   55 +
 docs/Configuration.md                    |   62 +
 docs/Daemon-Setup-and-Troubleshooting.md |  101 +
 docs/Home.md                             |   42 +
 docs/Installation.md                     |   51 +
 docs/Interface.md                        |  114 ++
 docs/Keybindings.md                      |   76 +
 src/config.c                             |  209 ++
 src/config.h                             |   33 +
 src/daemon.c                             |  170 ++
 src/daemon.h                             |   31 +
 src/http.c                               |  302 +++
 src/http.h                               |   25 +
 src/main.c                               |   69 +
 src/net.c                                |   56 +
 src/net.h                                |   10 +
 src/rpc.c                                |   91 +
 src/rpc.h                                |   32 +
 src/torrent.c                            |  348 ++++
 src/torrent.h                            |  117 ++
 src/ui/ui.c                              |  497 +++++
 src/ui/ui.h                              |  168 ++
 src/ui/ui_details.c                      |  290 +++
 src/ui/ui_dialogs.c                      |  419 ++++
 src/ui/ui_help.c                         |   68 +
 src/ui/ui_list.c                         |  515 +++++
 src/ui/ui_settings.c                     |  486 +++++
 src/ui/ui_splash.c                       |  113 ++
 src/util.c                               |  179 ++
 src/util.h                               |   26 +
 third_party/cJSON.c                      | 3206 ++++++++++++++++++++++++++++++
 third_party/cJSON.h                      |  306 +++
 37 files changed, 8489 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2e22939
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+*.o
+/transtui
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..3334c06
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,49 @@
+CC      ?= cc
+PREFIX  ?= /usr/local
+PKGCFG  := $(shell pkg-config --exists ncursesw && echo yes)
+
+CFLAGS  ?= -Os -Wall -Wextra -std=c11 -D_GNU_SOURCE
+CPPFLAGS := -Isrc -Ithird_party
+LDFLAGS ?=
+
+ifeq ($(PKGCFG),yes)
+    CFLAGS  += $(shell pkg-config --cflags ncursesw)
+    LDLIBS  += $(shell pkg-config --libs ncursesw)
+else
+    LDLIBS  += -lncursesw
+endif
+
+SRCS := $(wildcard src/*.c) $(wildcard src/ui/*.c) third_party/cJSON.c
+OBJS := $(SRCS:.c=.o)
+BIN  := transtui
+
+.PHONY: all clean release install install-desktop
+
+all: $(BIN)
+
+$(BIN): $(OBJS)
+	$(CC) $(CFLAGS) $(OBJS) -o $@ $(LDFLAGS) $(LDLIBS)
+
+%.o: %.c
+	$(CC) $(CFLAGS) $(CPPFLAGS) -c $< -o $@
+
+release: CFLAGS += -DNDEBUG
+release: clean $(BIN)
+	strip $(BIN)
+
+install: release
+	install -Dm755 $(BIN) $(DESTDIR)$(PREFIX)/bin/$(BIN)
+
+# Registers transtui as the handler for magnet: links and .torrent files, so
+# "Open with" in a browser/file manager routes straight to `transtui --add`
+# (see desktop/transtui-add.desktop). Requires transtui already on PATH
+# (run `make install` first) and the xdg-utils/desktop-file-utils tools.
+install-desktop:
+	install -Dm644 desktop/transtui-add.desktop \
+		$(DESTDIR)$(HOME)/.local/share/applications/transtui-add.desktop
+	-update-desktop-database $(DESTDIR)$(HOME)/.local/share/applications 2>/dev/null
+	-xdg-mime default transtui-add.desktop x-scheme-handler/magnet
+	-xdg-mime default transtui-add.desktop application/x-bittorrent
+
+clean:
+	rm -f $(OBJS) $(BIN)
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ec486e4
--- /dev/null
+++ b/README.md
@@ -0,0 +1,49 @@
+# TransTUI
+
+A terminal client (ncurses) for the [Transmission](https://transmissionbt.com/)
+BitTorrent daemon, written in C. Talks directly to `transmission-daemon`'s
+JSON-RPC API - no `libcurl`, `jansson`, or other heavy dependency, just
+`libncursesw`.
+
+```
+╭─ Filter ─────╮╭─ All (2) ───────────────────────────────────────────────╮
+│ ❯ All        ││  Name            Size        %  Status       Down   Up  │
+│   Downloading││  Debian netinst  667 MiB  100%  Seeding      0 B/s  12K/s│
+│   Uploading  ││  Ubuntu ISO      3.7 GiB    75%  Downloading 5M/s   0 B/s│
+│   Paused     ││                                                         │
+│   Completed  ││                                                         │
+╰──────────────╯╰─────────────────────────────────────────────────────────╯
+  ↑↓  Navigate  Tab  Filter  Enter  Details  Space  Select  s/S/p  Start/Stop
+  a  Add  d  Remove  l  Speed  o/O  Sort  /  Search  g  Settings
+```
+
+## Quick start
+
+```sh
+sudo apt install build-essential libncurses-dev pkg-config   # build deps
+make && make install
+transtui
+```
+
+Connects to `127.0.0.1:9091` by default. No daemon running, or it requires
+a password you don't know? `transtui` detects that and helps you fix it
+interactively at startup - see the
+[wiki](docs/Daemon-Setup-and-Troubleshooting.md) for details.
+
+## Documentation
+
+Full documentation lives in the [wiki](docs/Home.md):
+
+- **[Installation](docs/Installation.md)** - dependencies, building, installing
+- **[Configuration](docs/Configuration.md)** - `config.ini`, command-line flags
+- **[Interface](docs/Interface.md)** - a tour through the views
+- **[Keybindings](docs/Keybindings.md)** - full reference
+- **[Daemon: setup & troubleshooting](docs/Daemon-Setup-and-Troubleshooting.md)**
+- **[Browser Integration](docs/Browser-Integration.md)** - add torrents
+  directly from magnet links/`.torrent` files
+- **[Architecture](docs/Architecture.md)** - how the code is structured and why
+
+## License
+
+`third_party/cJSON.c`/`.h` is [cJSON](https://github.com/DaveGamble/cJSON),
+MIT licensed.
diff --git a/desktop/transtui-add.desktop b/desktop/transtui-add.desktop
new file mode 100644
index 0000000..21ab32a
--- /dev/null
+++ b/desktop/transtui-add.desktop
@@ -0,0 +1,9 @@
+[Desktop Entry]
+Type=Application
+Name=TransTUI (add torrent)
+Comment=Adds a magnet link or .torrent file to Transmission via transtui
+Exec=transtui --add %u
+Terminal=false
+NoDisplay=true
+MimeType=x-scheme-handler/magnet;application/x-bittorrent;
+Categories=Network;FileTransfer;
diff --git a/docs/Architecture.md b/docs/Architecture.md
new file mode 100644
index 0000000..b2c9202
--- /dev/null
+++ b/docs/Architecture.md
@@ -0,0 +1,113 @@
+# Architecture
+
+## Module overview
+
+```
+src/
+├── main.c            CLI parsing, switches between TUI mode and --add mode
+├── net.c/.h           raw TCP connection (connect w/ timeout)
+├── http.c/.h           minimal HTTP/1.1 client on top of net.h (POST, headers,
+                        Content-Length and chunked response parsing)
+├── rpc.c/.h            Transmission JSON-RPC layer: session-id/409 handling
+├── torrent.c/.h        data model + parsing of torrent-get responses into structs
+├── config.c/.h         config.ini reading/writing, CLI flags
+├── daemon.c/.h         start local transmission-daemon, fix RPC auth (sudo)
+├── util.c/.h           string formatting, base64, file I/O, small string utils
+└── ui/
+    ├── ui.c/.h          shared types (AppState), main loop, colors, frames
+    ├── ui_list.c        list view: sidebar, columns, sort/filter/search
+    ├── ui_details.c     detail view: General/Files/Peers/Trackers
+    ├── ui_settings.c    settings screen
+    ├── ui_dialogs.c     confirmations/text prompts/messages
+    ├── ui_splash.c      splash screen
+    └── ui_help.c        help screen
+
+third_party/cJSON.c/.h  vendored JSON parser (github.com/DaveGamble/cJSON)
+```
+
+## Design decisions
+
+### No `libcurl`/`jansson`/`json-c`
+
+Linking in `libcurl` would have pulled in openssl/zlib/nghttp2 and friends
+and made the binary considerably larger for a protocol that's fundamentally
+just "a POST with a JSON body". `http.c` implements exactly what
+Transmission's RPC spec requires: an HTTP/1.1 POST, reading the response
+with either `Content-Length` or `Transfer-Encoding: chunked`, and parsing
+the `X-Transmission-Session-Id` header. `cJSON` is vendored as a single
+`.c`/`.h` file instead of a system dependency. Result: the binary lands
+around 70-90 KB stripped, with only `libncursesw` as a runtime dependency.
+
+### Transmission's CSRF handshake
+
+Transmission's RPC requires an `X-Transmission-Session-Id` header; a call
+without one (or with the wrong session id) gets a `409 Conflict` back with
+the correct id in the response header. `rpc_call()` in `rpc.c` makes this
+transparent: on `409` the new session id is saved and the call is retried
+once automatically - the rest of the code never has to think about it.
+
+### Single-threaded event loop
+
+No background thread. The main loop (`ui_run()` in `ui.c`) sets
+`timeout(200)` on `getch()`; every time it times out (no keypress), it
+checks whether `poll_interval_ms` has elapsed since the last list refresh,
+and if so does a new RPC refresh. All RPC calls are synchronous - on a
+local network that's fast enough to feel instant, and it avoids the whole
+class of race conditions a threaded solution would have introduced.
+
+### Read on demand
+
+`torrent-get` for the full list only fetches summary fields (name, size,
+status, speeds, ...). Files/peers/trackers are only fetched when the
+detail view opens for a specific torrent, and are only kept in memory
+while that view is open - not for every torrent on every refresh.
+
+### Memory reuse in the list
+
+`TorrentList` in `torrent.c` only reallocates its array when it needs to
+grow, never shrinks, and never reallocates fresh on every refresh.
+Selection (`space` marking) in the UI is tied to the torrent `id`, not the
+array index, and is restored after every refresh since the daemon's
+response order isn't guaranteed stable.
+
+### Shared UI building blocks
+
+`ui_box()`/`ui_box_title()` (rounded frames) and `ui_draw_footer()`
+(key chips) in `ui.c` are used by every view for a consistent look. Column
+positions in the list view are computed once per render into a
+`ContentCols` struct and reused for both the header and the rows, so they
+can never drift out of sync with each other. Popup views (dialogs,
+confirmations, prompts) render with `open_box()`/`close_box()` in
+`ui_dialogs.c`, which call `touchwin(stdscr)` after closing so the next
+full-screen redraw doesn't skip cells it thinks already match. The
+settings view used to render as a popup layered over the list view as a
+backdrop, but that turned out unreliable on some real terminals (the view
+state changed correctly but didn't always visibly redraw) - it's now a
+dedicated full-screen view instead (same pattern as the help screen),
+which sidesteps that whole class of overlapping-window issue.
+
+### Two pinned environment details
+
+- **`LC_NUMERIC` pinned to `"C"`.** `setlocale(LC_ALL, "")` (needed for
+  correct UTF-8 rendering in ncursesw) also changes the decimal separator
+  according to the system locale. cJSON's number parsing uses `strtod()`
+  under the active locale, so under e.g. a locale with a comma decimal
+  separator, `"1.0"` in a JSON response would be parsed incorrectly and
+  desync the whole parser. `LC_NUMERIC` is therefore reset to `"C"`
+  immediately after, while the rest of the locale (for UTF-8/character
+  width) is left as the system's own.
+- **`set_escdelay(25)`.** ncurses waits 1 second by default after a lone
+  `Esc` keypress to decide whether it's a standalone Esc or the start of a
+  function-key sequence. That makes Esc feel noticeably sluggish; the
+  value is lowered to 25 ms at startup.
+
+### `daemon.c`: never shells out a password
+
+When `transtui` rewrites `/etc/transmission-daemon/settings.json` (see
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md)),
+the file is read/modified/written via cJSON to a temp file, and `sudo
+systemctl stop/start` plus `sudo cp` are then each run separately via
+`fork()`+`execvp()` with argv arrays - never through a shell string. The
+user-chosen password therefore never passes through a shell or a `sed`
+expression, so there's nothing to escape and no injection surface
+regardless of what characters it contains.
diff --git a/docs/Browser-Integration.md b/docs/Browser-Integration.md
new file mode 100644
index 0000000..947a02e
--- /dev/null
+++ b/docs/Browser-Integration.md
@@ -0,0 +1,55 @@
+# Browser Integration
+
+`transtui` can run headless to just add a torrent and exit - handy for
+registering it as the target when you click a magnet link or open a
+downloaded `.torrent` file.
+
+## `--add` mode
+
+```sh
+transtui --add "magnet:?xt=urn:btih:..."
+transtui --add /path/to/file.torrent
+transtui --add "file:///path/to/file.torrent"   # file:// URIs are supported too
+```
+
+Adds the torrent to the daemon (using the usual host/port/user/pass
+configuration from `config.ini`, optionally overridden with
+`-H`/`-P`/`-u`/`-p`, see [Configuration](Configuration.md)) and exits
+immediately - no ncurses screen opens at all. Prints a result to
+stdout/stderr and sets the exit code (`0` = success, `1` = error).
+
+`file://` URIs are decoded (percent-decoding, e.g. `%20` → space) before
+the file is read, since desktop environments often send a `file://` URI
+rather than a plain path when invoking an "open with" handler.
+
+## Registering as the default handler
+
+```sh
+make install           # transtui must be in PATH
+make install-desktop   # installs desktop/transtui-add.desktop and runs xdg-mime
+```
+
+`make install-desktop`:
+
+1. Installs `desktop/transtui-add.desktop`
+   (`Exec=transtui --add %u`, `Terminal=false`) to
+   `~/.local/share/applications/`.
+2. Runs `update-desktop-database`.
+3. Runs `xdg-mime default transtui-add.desktop x-scheme-handler/magnet`
+   and the same for `application/x-bittorrent`.
+
+After that: clicking a magnet link in the browser, or "Open with" on a
+downloaded `.torrent` file in the file manager, adds it to the daemon
+immediately - no terminal or TUI pops up.
+
+## Troubleshooting
+
+- **Nothing happens when you click a magnet link**: check that `transtui`
+  is on `PATH` (`which transtui`) and that
+  `xdg-mime query default x-scheme-handler/magnet` points at
+  `transtui-add.desktop`.
+- **"could not add torrent"**: the same connection problems as in the
+  regular TUI mode can happen here too (wrong host/port/password) - see
+  [Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md).
+  Since `--add` isn't interactive, no help dialogs show up here; run
+  `transtui` (without `--add`) once to resolve the connection first.
diff --git a/docs/Configuration.md b/docs/Configuration.md
new file mode 100644
index 0000000..38c4f34
--- /dev/null
+++ b/docs/Configuration.md
@@ -0,0 +1,62 @@
+# Configuration
+
+## Config file
+
+`transtui` reads (and creates, with defaults, if missing)
+`~/.config/transtui/config.ini` (or `$XDG_CONFIG_HOME/transtui/config.ini`
+if that environment variable is set):
+
+```ini
+host = 127.0.0.1
+port = 9091
+username =
+password =
+poll_interval_ms = 2000
+show_splash = 1
+```
+
+| Field | Meaning |
+|---|---|
+| `host` | Transmission daemon address |
+| `port` | RPC port (Transmission's default is 9091) |
+| `username` / `password` | RPC authentication, empty if the daemon doesn't require it |
+| `poll_interval_ms` | How often the list auto-refreshes (min. 250 ms) |
+| `show_splash` | `1`/`0` - show the splash screen at startup |
+
+The file is written automatically when you change connection or
+splash-screen settings in the app (`g`, see [Interface](Interface.md#settings-g)) -
+you rarely need to edit it by hand.
+
+## Command-line flags
+
+```
+transtui [-H host] [-P port] [-u user] [-p pass] [-i ms]
+transtui --add <magnet|url|file>  [-H host] [-P port] [-u user] [-p pass]
+```
+
+| Flag | Meaning |
+|---|---|
+| `-H`, `--host <host>` | Override `host` for this run |
+| `-P`, `--port <port>` | Override `port` |
+| `-u`, `--user <name>` | Override `username` |
+| `-p`, `--pass <pass>` | Override `password` |
+| `-i`, `--interval <ms>` | Override `poll_interval_ms` |
+| `-A`, `--add <source>` | Add a torrent and exit immediately, see [Browser Integration](Browser-Integration.md) |
+| `-h`, `--help` | Show help |
+
+The flags do **not** overwrite `config.ini` - they only apply to that run.
+That makes them good for one-off runs, scripts, or pointing at a different
+daemon temporarily without changing your usual setup.
+
+## Connecting to a remote daemon
+
+```sh
+transtui -H 192.168.1.10 -P 9091 -u username -p password
+```
+
+Works against any Transmission daemon that exposes the RPC, local or over
+the network - `transtui` just needs to reach the port. The automatic "no
+daemon found" help (starting/configuring the daemon for you) only applies
+to `127.0.0.1`/`localhost` though, for obvious reasons: it can't `sudo`
+into another machine. See
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md).
diff --git a/docs/Daemon-Setup-and-Troubleshooting.md b/docs/Daemon-Setup-and-Troubleshooting.md
new file mode 100644
index 0000000..3fc8457
--- /dev/null
+++ b/docs/Daemon-Setup-and-Troubleshooting.md
@@ -0,0 +1,101 @@
+# Daemon: setup & troubleshooting
+
+If `transtui` can't connect at startup, and `host` points at the local
+machine (`127.0.0.1`/`localhost`), it tries to help automatically - what
+happens depends on *why* the connection failed.
+
+## Case 1: No daemon responds at all
+
+You're asked:
+
+> No daemon is responding at 127.0.0.1:9091. Start transmission-daemon?
+
+If you answer `y`, `transmission-daemon` is launched (must be in `PATH`)
+and `transtui` tries to connect for a few seconds while it starts up.
+
+**If `transmission-daemon` isn't installed**, you instead get a clear error
+message with an install hint:
+
+```sh
+sudo apt install transmission-daemon     # Debian/Ubuntu
+```
+
+(on other distributions a generic pointer to your package manager is shown,
+e.g. `dnf install transmission-daemon` on Fedora).
+
+## Case 2: The daemon responds but requires a password you don't know (HTTP 401)
+
+Very common if you installed via `apt`: Debian's package starts
+`transmission-daemon` as a **systemd service** right at install time, with
+RPC authentication enabled and a **randomly generated password** that's
+never shown in plaintext anywhere. There's no way to "read back" that
+password afterward.
+
+`transtui` detects this specifically (distinguishes "no daemon responding"
+from "daemon responds with 401") and asks:
+
+> The daemon at 127.0.0.1:9091 is responding but requires a password we
+> don't know. Fix it now (requires sudo)?
+
+If you answer `y` you get to choose:
+
+- **Set a custom password** - you enter a password, `transtui` sets
+  `rpc-username` to `transmission` and that password in the daemon's
+  config, and saves the same credentials in your own `config.ini` so the
+  next run connects automatically.
+- **No** (disable password protection entirely) -
+  `rpc-authentication-required` is set to `false`. Convenient if you run
+  everything locally on your own machine and don't care about access
+  control on the RPC port.
+
+How it's done technically: `transtui` reads
+`/etc/transmission-daemon/settings.json`, only changes the affected fields
+(via JSON parsing, no text manipulation), writes the result to a temp
+file, then runs `sudo systemctl stop transmission-daemon`,
+`sudo cp <tempfile> /etc/transmission-daemon/settings.json`, and
+`sudo systemctl start transmission-daemon` as three separate commands (no
+shell involved), so your password can never accidentally be interpreted as
+a shell command regardless of what characters it contains. The `sudo`
+prompt appears in the same terminal `transtui` is running in.
+
+### Manually, if you'd rather do it yourself
+
+```sh
+sudo systemctl stop transmission-daemon
+sudo nano /etc/transmission-daemon/settings.json
+```
+
+Change:
+
+```json
+"rpc-authentication-required": true,
+"rpc-username": "transmission",
+"rpc-password": "YourPassword",
+```
+
+(or set `rpc-authentication-required` to `false` to disable password
+protection entirely). Save, then restart:
+
+```sh
+sudo systemctl start transmission-daemon
+```
+
+Transmission hashes the password in the file automatically the next time
+the daemon starts - that's expected, not a bug.
+
+## Triggering the help again later
+
+Both flows can be triggered again: open settings (`g`) and press `r` when
+the title shows "not connected".
+
+## Other common errors
+
+| Symptom | Likely cause |
+|---|---|
+| `Connection error: cannot connect to host:port: Connection refused` | No daemon is listening on the given host/port - see above for `127.0.0.1`, otherwise check that the remote daemon is running and the port is reachable (firewall?) |
+| `HTTP error 401 from server` (in the status line, without the auto-help appearing) | Host isn't `127.0.0.1`/`localhost` - can't be auto-fixed on a remote machine, set `-u`/`-p` or edit the settings manually with the right credentials |
+| `RPC error: ...` | The daemon responded but the RPC call failed for another reason - the text after the colon comes directly from Transmission's own error reporting |
+| `could not parse JSON response` | Unusual - check that `host`/`port` actually point at a Transmission daemon and not something else that happens to respond on that port |
+
+See also [Configuration](Configuration.md) for how host/port/user/pass are
+set, and [Architecture](Architecture.md) for protocol details.
diff --git a/docs/Home.md b/docs/Home.md
new file mode 100644
index 0000000..04812d9
--- /dev/null
+++ b/docs/Home.md
@@ -0,0 +1,42 @@
+# TransTUI wiki
+
+TransTUI is a terminal client (ncurses) for the [Transmission](https://transmissionbt.com/)
+BitTorrent daemon, written in C. It talks directly to `transmission-daemon`'s
+JSON-RPC API - no `libcurl`, `jansson`, or other heavy dependency required.
+
+```
+              ╭─ TransTUI ──────────────────────────────────────────╮
+              │                                                     │
+              │   █████ ████   ███  █   █  ████ █████ █   █ █████   │
+              │     █   █   █ █   █ ██  █ █       █   █   █   █     │
+              │     █   ████  █████ █ █ █  ███    █   █   █   █     │
+              │     █   █  █  █   █ █  ██     █   █   █   █   █     │
+              │     █   █   █ █   █ █   █ ████    █    ███  █████   │
+              │                                                     │
+              │               Transmission TUI Client               │
+              ╰─────────────────────────────────────────────────────╯
+```
+
+## Contents
+
+- **[Installation](Installation.md)** - dependencies, building, installing
+- **[Configuration](Configuration.md)** - `config.ini`, command-line flags
+- **[Interface](Interface.md)** - tour: sidebar, list view, detail view,
+  settings screen, splash screen
+- **[Keybindings](Keybindings.md)** - full reference
+- **[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md)**
+  - no daemon found, HTTP 401 authentication, common errors
+- **[Browser Integration](Browser-Integration.md)** - `--add` mode,
+  `.desktop` file for magnet links/`.torrent` files
+- **[Architecture](Architecture.md)** - how the code is structured and why
+
+## Quick start
+
+```sh
+make && make install
+transtui
+```
+
+See [Installation](Installation.md) for dependencies and
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md) if
+`transtui` can't connect to a daemon.
diff --git a/docs/Installation.md b/docs/Installation.md
new file mode 100644
index 0000000..5e6edeb
--- /dev/null
+++ b/docs/Installation.md
@@ -0,0 +1,51 @@
+# Installation
+
+## Dependencies
+
+**Building:** `gcc`/`cc`, `make`, `pkg-config`, `libncursesw-dev` (or the
+equivalent dev package for your distro, e.g. `ncurses-devel` on Fedora).
+
+**Running:** only `libncursesw` (shared via the system, already installed if
+you could build). No `libcurl`, `jansson`, or `json-c` needed - the HTTP
+client (raw TCP socket, own HTTP/1.1 parser) and JSON handling
+([cJSON](https://github.com/DaveGamble/cJSON), vendored in `third_party/`)
+are built into the binary. See [Architecture](Architecture.md) for why.
+
+Debian/Ubuntu:
+
+```sh
+sudo apt install build-essential libncursesw6 libncurses-dev pkg-config
+```
+
+## Build
+
+```sh
+make            # regular build (-Os -Wall -Wextra), binary: ./transtui
+make release    # same, but stripped binary (smaller file size)
+make clean       # removes build artifacts
+```
+
+The binary typically lands around 70-90 KB stripped, thanks to no heavy
+libraries being linked in.
+
+## Install
+
+```sh
+make install    # copies to $PREFIX/bin (default /usr/local/bin)
+```
+
+Run as root or set `DESTDIR`/`PREFIX` for a different location, e.g.:
+
+```sh
+make install PREFIX=$HOME/.local
+```
+
+After installing, make sure `$PREFIX/bin` is in your `PATH` if you're using
+a non-standard path - otherwise `transtui --add ...` from the browser won't
+find the binary (see [Browser Integration](Browser-Integration.md)).
+
+## Next steps
+
+- [Configuration](Configuration.md) - connect to your `transmission-daemon`
+- [Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md) -
+  if you don't already have a daemon running
diff --git a/docs/Interface.md b/docs/Interface.md
new file mode 100644
index 0000000..f2da522
--- /dev/null
+++ b/docs/Interface.md
@@ -0,0 +1,114 @@
+# Interface
+
+The layout is inspired by [torrra](https://github.com/stabldev/torrra): a
+left sidebar with filter categories, a main panel with the torrent list, and
+an always-visible footer with keybindings. All panels and popups are drawn
+with rounded corners (`╭─╮`/`╰─╯`) in a consistent style.
+
+## Splash screen
+
+```
+              ╭─ TransTUI ──────────────────────────────────────────╮
+              │                                                     │
+              │   █████ ████   ███  █   █  ████ █████ █   █ █████   │
+              │     █   █   █ █   █ ██  █ █       █   █   █   █     │
+              │     █   ████  █████ █ █ █  ███    █   █   █   █     │
+              │     █   █  █  █   █ █  ██     █   █   █   █   █     │
+              │     █   █   █ █   █ █   █ ████    █    ███  █████   │
+              │                                                     │
+              │               Transmission TUI Client               │
+              │      Press any key to continue…                     │
+              ╰─────────────────────────────────────────────────────╯
+```
+
+A blocky, white "16-bit"-style splash screen shows for up to 3 seconds or
+until you press a key. Can be turned off permanently in settings
+(`g` → *Splash screen: On/Off*), see [Configuration](Configuration.md).
+
+## List view
+
+```
+╭─ Filter ─────╮╭─ All (2) ───────────────────────────────────────────────╮
+│ ❯ All        ││  Name            Size        %  Status       Down   Up  │
+│   Downloading││  Debian netinst  667 MiB  100%  Seeding      0 B/s  12K/s│
+│   Uploading  ││  Ubuntu ISO      3.7 GiB    75%  Downloading 5M/s   0 B/s│
+│   Paused     ││                                                         │
+│   Completed  ││                                                         │
+╰──────────────╯╰─────────────────────────────────────────────────────────╯
+  ↑↓  Navigate  Tab  Filter  Enter  Details  Space  Select  s/S/p  Start/Stop
+  a  Add  d  Remove  l  Speed  o/O  Sort  /  Search  g  Settings
+```
+
+- **The sidebar** (left) filters the list live by category: All,
+  Downloading, Uploading, Paused, Completed.
+- **The main panel** (right) shows torrents matching the filter, with
+  columns for size, progress, status, speeds, ETA, and ratio (the last two
+  are automatically hidden on narrow terminals to give the name more room).
+- **The footer** shows keybindings as chips, context-dependent on whether
+  the sidebar or the list has focus.
+- **The status line** (above the footer) shows total speeds, torrent count,
+  and sort column - or a temporary status message after an action (e.g.
+  "Started 1 torrent(s)").
+
+`Tab` switches keyboard focus between the sidebar and the list. With focus
+in the sidebar, `↑`/`↓` moves the cursor between categories and applies the
+filter immediately (live preview); `Enter`/`→`/`Tab` moves focus back to
+the list. Full key reference: [Keybindings](Keybindings.md).
+
+## Detail view
+
+Opened with `Enter` on a selected torrent. Four tabs (`Tab` or `1`-`4`):
+
+- **General** - hash, size, progress, speed, ratio, peers, speed limits,
+  folder, dates, any error message.
+- **Files** - every file in the torrent with size, progress, priority, and
+  wanted/skip status. `space` toggles wanted/skip, `+`/`-` changes priority
+  (low/normal/high) - changes are sent to the daemon immediately.
+- **Peers** - connected peers: address, client, progress, speeds, flags.
+- **Trackers** - tracker URLs per tier, latest announce result,
+  seeders/leechers.
+
+## Settings (`g`)
+
+Opens as its own full-screen view (same style as the help screen) - even if
+no daemon is connected, since otherwise it would be impossible to fix a
+wrong host/port. Contains:
+
+- **Connection** (Host, Port, Username, Password) - saved to `config.ini`
+  and applied immediately to the running session. Does *not* replace the
+  command-line flags (`-H`/`-P`/`-u`/`-p`), which are still read at startup
+  and override what's in `config.ini`.
+- **Splash screen** (On/Off).
+- **Download folder** and speed limits/ratio limit/alt-speed schedule -
+  read/written directly against the daemon via `session-get`/`session-set`,
+  not saved locally.
+
+`↑`/`↓` navigates the fields, `Enter`/`space` changes a value or toggles
+on/off, `Esc`/`q` closes. If the daemon isn't connected it's shown in the
+title, and `r` offers help fixing it - see
+[Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md).
+
+## Dialogs
+
+Confirmations (`[y]es`/`[n]o`), text prompts (e.g. when adding a torrent
+manually or editing a field), and message boxes are drawn as smaller,
+centered popup boxes in the same rounded style, and can stack (e.g. "also
+delete data on disk?" on top of "remove torrent?").
+
+## Add torrent (`a`)
+
+Asks first whether to browse for a local `.torrent` file or type a
+magnet link/URL/path by hand - magnet links and remote URLs obviously
+can't be browsed to. Choosing to browse opens a directory picker starting
+in `$HOME` (remembering the last folder you visited for next time),
+showing only subfolders and `.torrent` files to keep it uncluttered:
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | move selection |
+| `Enter` | open the selected folder, or select the highlighted `.torrent` file |
+| `Backspace` / `←` | go up one folder (also listed as `..`) |
+| `Esc` / `q` | cancel |
+
+After picking a file (or typing a source), you're asked for a download
+folder (blank = the daemon's default).
diff --git a/docs/Keybindings.md b/docs/Keybindings.md
new file mode 100644
index 0000000..287eaa2
--- /dev/null
+++ b/docs/Keybindings.md
@@ -0,0 +1,76 @@
+# Keybindings
+
+Full reference. See also the built-in help screen (`?` from the list view)
+and the footer at the bottom of every view, which shows a context-dependent
+subset.
+
+## List view - sidebar has focus
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | move the cursor between filter categories, applies immediately |
+| `Enter` / `→` / `Tab` | move focus to the list |
+| `g` | open settings |
+| `?` | help |
+| `q` | quit |
+
+## List view - list has focus
+
+| Key | Function |
+|---|---|
+| `Tab` / `←` | move focus to the sidebar |
+| `↑`/`k`, `↓`/`j` | move selection |
+| `PgUp`/`PgDn` | page up/down |
+| `Home`/`End` | jump to top/bottom of the list |
+| `Enter` | show details for the selected torrent |
+| `space` | select/deselect torrent (for batch actions on multiple at once) |
+| `s` | start |
+| `S` | stop |
+| `p` | toggle start/stop for the selected torrent |
+| `v` | verify (recheck) |
+| `d` / `Delete` | remove (asks whether to delete data on disk) |
+| `l` | set speed limit for selected/marked |
+| `a` | add torrent - choose to browse for a local `.torrent` file, or type a magnet link/URL/path |
+| `r` | force refresh |
+| `o` | next sort column |
+| `O` | reverse sort direction |
+| `/` | search by name |
+| `Esc` | clear search, otherwise deselect all selected |
+| `g` | open settings |
+| `?` | help |
+| `q` | quit |
+
+Batch actions (`s`/`S`/`p`/`v`/`d`/`l`) act on all `space`-selected
+torrents, or just the highlighted row if none are selected.
+
+## Detail view
+
+| Key | Function |
+|---|---|
+| `Tab` | next tab |
+| `Shift+Tab` | previous tab |
+| `1`-`4` | jump directly to a tab (General/Files/Peers/Trackers) |
+| `↑`/`k`, `↓`/`j` | move selection (Files/Peers/Trackers) |
+| `space` (Files) | toggle wanted/skip for the selected file |
+| `+` / `-` (Files) | raise/lower download priority for the selected file |
+| `Esc` / `q` / `Backspace` | back to the list |
+
+## Settings (opened with `g`)
+
+| Key | Function |
+|---|---|
+| `↑`/`k`, `↓`/`j` | navigate the fields |
+| `Enter` / `space` | change value (text field) or toggle on/off (boolean field) |
+| `r` | (only visible when not connected) try reconnecting / offer help - see [Daemon: setup & troubleshooting](Daemon-Setup-and-Troubleshooting.md) |
+| `Esc` / `q` | close |
+
+## Dialogs (confirmations, text prompts, messages)
+
+| Key | Function |
+|---|---|
+| `y` | yes (confirmations) |
+| `n` / `Esc` | no / cancel |
+| letters/digits | type text (in text prompts) |
+| `Backspace` | delete character |
+| `Enter` | confirm text |
+| any key | close message boxes |
diff --git a/src/config.c b/src/config.c
new file mode 100644
index 0000000..680e1cf
--- /dev/null
+++ b/src/config.c
@@ -0,0 +1,209 @@
+#include "config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+static void set_defaults(Config *cfg)
+{
+    memset(cfg, 0, sizeof(*cfg));
+    snprintf(cfg->host, sizeof(cfg->host), "127.0.0.1");
+    cfg->port = 9091;
+    cfg->poll_interval_ms = 2000;
+    cfg->show_splash = 1;
+}
+
+static int resolve_path(char *out, size_t n)
+{
+    const char *xdg = getenv("XDG_CONFIG_HOME");
+    if (xdg && xdg[0]) {
+        snprintf(out, n, "%s/transtui/config.ini", xdg);
+        return 0;
+    }
+    const char *home = getenv("HOME");
+    if (!home || !home[0])
+        return -1;
+    snprintf(out, n, "%s/.config/transtui/config.ini", home);
+    return 0;
+}
+
+static void mkdir_parents(const char *path)
+{
+    char tmp[1024];
+    snprintf(tmp, sizeof(tmp), "%s", path);
+    for (char *p = tmp + 1; *p; p++) {
+        if (*p == '/') {
+            *p = '\0';
+            mkdir(tmp, 0755);
+            *p = '/';
+        }
+    }
+}
+
+static int write_default_file(const char *path)
+{
+    char dir[1024];
+    snprintf(dir, sizeof(dir), "%s", path);
+    char *slash = strrchr(dir, '/');
+    if (slash) {
+        *slash = '\0';
+        mkdir_parents(dir);
+        mkdir(dir, 0755);
+    }
+
+    FILE *f = fopen(path, "w");
+    if (!f)
+        return -1;
+    fprintf(f,
+            "# transtui config\n"
+            "host = 127.0.0.1\n"
+            "port = 9091\n"
+            "username =\n"
+            "password =\n"
+            "poll_interval_ms = 2000\n"
+            "show_splash = 1\n");
+    fclose(f);
+    return 0;
+}
+
+static char *trim(char *s)
+{
+    while (*s == ' ' || *s == '\t')
+        s++;
+    size_t n = strlen(s);
+    while (n > 0 && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r' || s[n - 1] == '\n'))
+        s[--n] = '\0';
+    return s;
+}
+
+static void parse_file(Config *cfg, FILE *f)
+{
+    char line[1024];
+    while (fgets(line, sizeof(line), f)) {
+        char *l = trim(line);
+        if (l[0] == '\0' || l[0] == '#' || l[0] == ';')
+            continue;
+        char *eq = strchr(l, '=');
+        if (!eq)
+            continue;
+        *eq = '\0';
+        char *key = trim(l);
+        char *val = trim(eq + 1);
+
+        if (strcmp(key, "host") == 0)
+            snprintf(cfg->host, sizeof(cfg->host), "%s", val);
+        else if (strcmp(key, "port") == 0)
+            cfg->port = atoi(val);
+        else if (strcmp(key, "username") == 0)
+            snprintf(cfg->username, sizeof(cfg->username), "%s", val);
+        else if (strcmp(key, "password") == 0)
+            snprintf(cfg->password, sizeof(cfg->password), "%s", val);
+        else if (strcmp(key, "poll_interval_ms") == 0)
+            cfg->poll_interval_ms = atoi(val);
+        else if (strcmp(key, "show_splash") == 0)
+            cfg->show_splash = (strcmp(val, "0") != 0 && strcasecmp(val, "false") != 0);
+    }
+}
+
+int config_load(Config *cfg, char *err, size_t errlen)
+{
+    set_defaults(cfg);
+
+    if (resolve_path(cfg->path, sizeof(cfg->path)) != 0) {
+        snprintf(err, errlen, "could not find HOME to resolve the config file");
+        return -1;
+    }
+
+    FILE *f = fopen(cfg->path, "r");
+    if (!f) {
+        if (write_default_file(cfg->path) != 0) {
+            snprintf(err, errlen, "could not create %s", cfg->path);
+            return -1;
+        }
+        return 0; /* defaults already set */
+    }
+
+    parse_file(cfg, f);
+    fclose(f);
+    if (cfg->port <= 0 || cfg->port > 65535)
+        cfg->port = 9091;
+    if (cfg->poll_interval_ms < 250)
+        cfg->poll_interval_ms = 250;
+    return 0;
+}
+
+int config_save(const Config *cfg, char *err, size_t errlen)
+{
+    if (!cfg->path[0]) {
+        snprintf(err, errlen, "no config path known");
+        return -1;
+    }
+    FILE *f = fopen(cfg->path, "w");
+    if (!f) {
+        snprintf(err, errlen, "could not write %s", cfg->path);
+        return -1;
+    }
+    fprintf(f,
+            "# transtui config\n"
+            "host = %s\n"
+            "port = %d\n"
+            "username = %s\n"
+            "password = %s\n"
+            "poll_interval_ms = %d\n"
+            "show_splash = %d\n",
+            cfg->host, cfg->port, cfg->username, cfg->password, cfg->poll_interval_ms,
+            cfg->show_splash);
+    fclose(f);
+    return 0;
+}
+
+static void usage(const char *prog)
+{
+    fprintf(stderr,
+            "Usage: %s [-H host] [-P port] [-u user] [-p pass] [-i ms]\n"
+            "       %s --add <magnet|url|file>  [-H host] [-P port] [-u user] [-p pass]\n"
+            "  -H host   Transmission daemon address (default 127.0.0.1)\n"
+            "  -P port   RPC port (default 9091)\n"
+            "  -u user   RPC username\n"
+            "  -p pass   RPC password\n"
+            "  -i ms     Poll interval in milliseconds (default 2000)\n"
+            "  -A, --add <source>\n"
+            "            Add a torrent (magnet link, URL, or .torrent file) and exit\n"
+            "            immediately without opening the UI - for registering transtui\n"
+            "            as the browser handler for magnet links/.torrent files.\n"
+            "  -h        Show this help\n",
+            prog, prog);
+}
+
+void config_apply_args(Config *cfg, int argc, char **argv, char *add_source, size_t add_source_size)
+{
+    if (add_source_size)
+        add_source[0] = '\0';
+
+    for (int i = 1; i < argc; i++) {
+        const char *a = argv[i];
+        if ((strcmp(a, "-H") == 0 || strcmp(a, "--host") == 0) && i + 1 < argc) {
+            snprintf(cfg->host, sizeof(cfg->host), "%s", argv[++i]);
+        } else if ((strcmp(a, "-P") == 0 || strcmp(a, "--port") == 0) && i + 1 < argc) {
+            cfg->port = atoi(argv[++i]);
+        } else if ((strcmp(a, "-u") == 0 || strcmp(a, "--user") == 0) && i + 1 < argc) {
+            snprintf(cfg->username, sizeof(cfg->username), "%s", argv[++i]);
+        } else if ((strcmp(a, "-p") == 0 || strcmp(a, "--pass") == 0) && i + 1 < argc) {
+            snprintf(cfg->password, sizeof(cfg->password), "%s", argv[++i]);
+        } else if ((strcmp(a, "-i") == 0 || strcmp(a, "--interval") == 0) && i + 1 < argc) {
+            cfg->poll_interval_ms = atoi(argv[++i]);
+        } else if ((strcmp(a, "-A") == 0 || strcmp(a, "--add") == 0) && i + 1 < argc) {
+            snprintf(add_source, add_source_size, "%s", argv[++i]);
+        } else if (strcmp(a, "-h") == 0 || strcmp(a, "--help") == 0) {
+            usage(argv[0]);
+            exit(0);
+        } else {
+            fprintf(stderr, "Unknown argument: %s\n\n", a);
+            usage(argv[0]);
+            exit(1);
+        }
+    }
+}
diff --git a/src/config.h b/src/config.h
new file mode 100644
index 0000000..dd9b7f9
--- /dev/null
+++ b/src/config.h
@@ -0,0 +1,33 @@
+#ifndef TRANSTUI_CONFIG_H
+#define TRANSTUI_CONFIG_H
+
+#include <stddef.h>
+
+typedef struct {
+    char host[256];
+    int port;
+    char username[128];
+    char password[128];
+    int poll_interval_ms;
+    int show_splash;
+    char path[1024]; /* resolved config file path, for messages */
+} Config;
+
+/* Loads ~/.config/transtui/config.ini (or $XDG_CONFIG_HOME/transtui/config.ini).
+ * Creates the file with sane defaults if it doesn't exist yet.
+ * Returns 0 on success (cfg always ends up with usable defaults), -1 only if
+ * the config directory couldn't be created/read at all (err filled). */
+int config_load(Config *cfg, char *err, size_t errlen);
+
+/* Writes cfg back to cfg->path (as resolved by config_load). Used when the
+ * user edits daemon/download settings from the in-app settings screen. */
+int config_save(const Config *cfg, char *err, size_t errlen);
+
+/* Applies -H/-P/-u/-p/-i command-line overrides on top of an already-loaded
+ * config. Prints usage and exits on -h/--help or a bad argument. If -A/--add
+ * <source> is given, its value is copied into add_source (left empty
+ * otherwise) - the caller uses that to switch into non-interactive
+ * add-and-exit mode instead of launching the TUI. */
+void config_apply_args(Config *cfg, int argc, char **argv, char *add_source, size_t add_source_size);
+
+#endif
diff --git a/src/daemon.c b/src/daemon.c
new file mode 100644
index 0000000..6212ea3
--- /dev/null
+++ b/src/daemon.c
@@ -0,0 +1,170 @@
+#include "daemon.h"
+#include "../third_party/cJSON.h"
+#include "util.h"
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <unistd.h>
+
+int daemon_host_is_local(const char *host)
+{
+    return strcmp(host, "127.0.0.1") == 0 || strcmp(host, "localhost") == 0 ||
+           strcmp(host, "::1") == 0;
+}
+
+int daemon_start(char *err, size_t errlen)
+{
+    pid_t pid = fork();
+    if (pid < 0) {
+        snprintf(err, errlen, "fork failed: %s", strerror(errno));
+        return -1;
+    }
+
+    if (pid == 0) {
+        /* transmission-daemon daemonizes (double-forks) itself by default,
+         * so this child just execs it and lets it detach on its own. Give
+         * it a clean session and /dev/null fds so it doesn't inherit our
+         * controlling terminal or fight ncurses for it. */
+        setsid();
+        int devnull = open("/dev/null", O_RDWR);
+        if (devnull >= 0) {
+            dup2(devnull, STDIN_FILENO);
+            dup2(devnull, STDOUT_FILENO);
+            dup2(devnull, STDERR_FILENO);
+            if (devnull > STDERR_FILENO)
+                close(devnull);
+        }
+        execlp("transmission-daemon", "transmission-daemon", (char *)NULL);
+        _exit(127); /* only reached if execlp failed */
+    }
+
+    int status = 0;
+    if (waitpid(pid, &status, 0) < 0) {
+        snprintf(err, errlen, "waitpid failed: %s", strerror(errno));
+        return -1;
+    }
+    if (WIFEXITED(status) && WEXITSTATUS(status) == 127) {
+        if (access("/etc/debian_version", F_OK) == 0)
+            snprintf(err, errlen,
+                      "transmission-daemon is not installed. Run: sudo apt install transmission-daemon");
+        else
+            snprintf(err, errlen, "transmission-daemon is not installed (install it via your package manager)");
+        return -1;
+    }
+    if (WIFEXITED(status) && WEXITSTATUS(status) != 0) {
+        snprintf(err, errlen, "transmission-daemon exited with code %d", WEXITSTATUS(status));
+        return -1;
+    }
+    if (WIFSIGNALED(status)) {
+        snprintf(err, errlen, "transmission-daemon was killed by signal %d", WTERMSIG(status));
+        return -1;
+    }
+    return 0;
+}
+
+/* Runs argv[0] with argv as its arguments (no shell involved - the caller's
+ * values, e.g. a user-chosen password, never pass through a shell so there's
+ * nothing to escape or inject). Inherits our stdin/stdout/stderr so sudo can
+ * prompt on the real terminal. */
+static int run_argv(char *const argv[], char *err, size_t errlen)
+{
+    pid_t pid = fork();
+    if (pid < 0) {
+        if (err)
+            snprintf(err, errlen, "fork failed: %s", strerror(errno));
+        return -1;
+    }
+    if (pid == 0) {
+        execvp(argv[0], argv);
+        _exit(127);
+    }
+    int status = 0;
+    if (waitpid(pid, &status, 0) < 0) {
+        if (err)
+            snprintf(err, errlen, "waitpid failed: %s", strerror(errno));
+        return -1;
+    }
+    if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+        if (err)
+            snprintf(err, errlen, "%s failed (cancelled at sudo?)", argv[0]);
+        return -1;
+    }
+    return 0;
+}
+
+#define TRANSMISSION_SETTINGS_PATH "/etc/transmission-daemon/settings.json"
+#define TRANSMISSION_SERVICE "transmission-daemon"
+
+int daemon_fix_auth(const char *username, const char *password, char *err, size_t errlen)
+{
+    size_t flen = 0;
+    unsigned char *data = read_file_all(TRANSMISSION_SETTINGS_PATH, &flen);
+    if (!data) {
+        snprintf(err, errlen, "cannot find %s - are you running Debian's transmission-daemon package?",
+                  TRANSMISSION_SETTINGS_PATH);
+        return -1;
+    }
+    cJSON *root = cJSON_ParseWithLength((const char *)data, flen);
+    free(data);
+    if (!root) {
+        snprintf(err, errlen, "could not parse %s", TRANSMISSION_SETTINGS_PATH);
+        return -1;
+    }
+
+    if (password && password[0]) {
+        cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-username", cJSON_CreateString(username));
+        cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-password", cJSON_CreateString(password));
+        cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-authentication-required", cJSON_CreateBool(1));
+    } else {
+        cJSON_ReplaceItemInObjectCaseSensitive(root, "rpc-authentication-required", cJSON_CreateBool(0));
+    }
+
+    char *out = cJSON_Print(root);
+    cJSON_Delete(root);
+    if (!out) {
+        snprintf(err, errlen, "out of memory");
+        return -1;
+    }
+
+    char tmp_path[] = "/tmp/transtui-settings-XXXXXX";
+    int fd = mkstemp(tmp_path);
+    if (fd < 0) {
+        snprintf(err, errlen, "mkstemp failed: %s", strerror(errno));
+        cJSON_free(out);
+        return -1;
+    }
+    size_t outlen = strlen(out);
+    ssize_t wr = write(fd, out, outlen);
+    close(fd);
+    cJSON_free(out);
+    if (wr < 0 || (size_t)wr != outlen) {
+        snprintf(err, errlen, "could not write temp file");
+        unlink(tmp_path);
+        return -1;
+    }
+
+    char *stop_argv[] = {"sudo", "systemctl", "stop", TRANSMISSION_SERVICE, NULL};
+    char *cp_argv[] = {"sudo", "cp", tmp_path, TRANSMISSION_SETTINGS_PATH, NULL};
+    char *start_argv[] = {"sudo", "systemctl", "start", TRANSMISSION_SERVICE, NULL};
+
+    int rc = run_argv(stop_argv, err, errlen);
+    if (rc == 0)
+        rc = run_argv(cp_argv, err, errlen);
+
+    /* Always try to bring the service back up, even if the copy failed,
+     * so we don't leave it stopped - but don't let a start failure here
+     * clobber a more specific error from the steps above. */
+    char start_err[128];
+    int start_rc = run_argv(start_argv, start_err, sizeof(start_err));
+    if (rc == 0 && start_rc != 0) {
+        snprintf(err, errlen, "%s", start_err);
+        rc = -1;
+    }
+
+    unlink(tmp_path);
+    return rc;
+}
diff --git a/src/daemon.h b/src/daemon.h
new file mode 100644
index 0000000..f8c1460
--- /dev/null
+++ b/src/daemon.h
@@ -0,0 +1,31 @@
+#ifndef TRANSTUI_DAEMON_H
+#define TRANSTUI_DAEMON_H
+
+#include <stddef.h>
+
+/* True if host refers to this machine (127.0.0.1/localhost/::1) - the only
+ * case where offering to spawn transmission-daemon locally makes sense. */
+int daemon_host_is_local(const char *host);
+
+/* Launches `transmission-daemon` (must be on PATH) and lets it daemonize
+ * itself in the background as it normally does. Returns 0 if the command
+ * was found and its (short-lived, since it double-forks) launcher process
+ * exited cleanly, -1 on failure (err filled, e.g. "not found on PATH"). */
+int daemon_start(char *err, size_t errlen);
+
+/* Rewrites the system transmission-daemon's RPC credentials (Debian's
+ * packaged /etc/transmission-daemon/settings.json) and restarts the service
+ * via `sudo systemctl` - for when the daemon is already running but RPC
+ * auth is enabled with a password nobody knows (the Debian package
+ * generates a random one that's never shown in plaintext).
+ *
+ * If password is NULL or empty, disables RPC auth entirely instead of
+ * setting a username/password. sudo will prompt on the controlling
+ * terminal - the caller is responsible for leaving curses mode first
+ * (def_prog_mode()+endwin(), then reset_prog_mode() after).
+ *
+ * Returns 0 on success, -1 on failure (err filled - e.g. sudo was denied,
+ * the settings file wasn't found, or the service failed to restart). */
+int daemon_fix_auth(const char *username, const char *password, char *err, size_t errlen);
+
+#endif
diff --git a/src/http.c b/src/http.c
new file mode 100644
index 0000000..c208c62
--- /dev/null
+++ b/src/http.c
@@ -0,0 +1,302 @@
+#include "http.h"
+#include "net.h"
+#include "util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+
+#define INITIAL_BUF 8192
+
+typedef struct {
+    char *data;
+    size_t len;
+    size_t cap;
+} Buf;
+
+static int buf_ensure(Buf *b, size_t extra)
+{
+    if (b->len + extra + 1 <= b->cap)
+        return 0;
+    size_t ncap = b->cap ? b->cap * 2 : INITIAL_BUF;
+    while (ncap < b->len + extra + 1)
+        ncap *= 2;
+    char *nd = realloc(b->data, ncap);
+    if (!nd)
+        return -1;
+    b->data = nd;
+    b->cap = ncap;
+    return 0;
+}
+
+static int buf_append(Buf *b, const char *s, size_t n)
+{
+    if (buf_ensure(b, n) != 0)
+        return -1;
+    memcpy(b->data + b->len, s, n);
+    b->len += n;
+    b->data[b->len] = '\0';
+    return 0;
+}
+
+/* Appends a NUL-terminated C string, sizing itself via strlen() so callers
+ * never have to hand-count literal lengths (a previous version did, and got
+ * it wrong, silently splicing NUL bytes into outgoing HTTP headers). */
+static int buf_append_str(Buf *b, const char *s)
+{
+    return buf_append(b, s, strlen(s));
+}
+
+/* Case-insensitive substring search (portable stand-in for strcasestr). */
+static const char *ci_find(const char *hay, size_t haylen, const char *needle)
+{
+    size_t nlen = strlen(needle);
+    if (nlen == 0 || nlen > haylen)
+        return NULL;
+    for (size_t i = 0; i + nlen <= haylen; i++) {
+        size_t j = 0;
+        for (; j < nlen; j++) {
+            if (tolower((unsigned char)hay[i + j]) != tolower((unsigned char)needle[j]))
+                break;
+        }
+        if (j == nlen)
+            return hay + i;
+    }
+    return NULL;
+}
+
+/* block is the raw response from the status line through the blank line
+ * that terminates the headers (block_len does NOT need to include the body). */
+static void extract_header(const char *block, size_t block_len, const char *name,
+                            char *out, size_t outsize)
+{
+    out[0] = '\0';
+    char pat[64];
+    snprintf(pat, sizeof(pat), "\r\n%s:", name);
+    const char *p = ci_find(block, block_len, pat);
+    if (!p)
+        return;
+
+    const char *v = p + 2 + strlen(name) + 1; /* skip \r\n NAME: */
+    const char *block_end = block + block_len;
+    while (v < block_end && (*v == ' ' || *v == '\t'))
+        v++;
+    const char *end = ci_find(v, (size_t)(block_end - v), "\r\n");
+    if (!end)
+        end = block_end;
+    size_t vlen = (size_t)(end - v);
+    if (vlen >= outsize)
+        vlen = outsize - 1;
+    memcpy(out, v, vlen);
+    out[vlen] = '\0';
+}
+
+static long extract_content_length(const char *headers, size_t headers_len)
+{
+    char v[32];
+    extract_header(headers, headers_len, "Content-Length", v, sizeof(v));
+    if (v[0] == '\0')
+        return -1;
+    return atol(v);
+}
+
+static int is_chunked(const char *headers, size_t headers_len)
+{
+    char v[64];
+    extract_header(headers, headers_len, "Transfer-Encoding", v, sizeof(v));
+    return strcasecmp(v, "chunked") == 0;
+}
+
+/* Decodes an HTTP chunked body in-place; returns decoded length, or -1 on malformed input. */
+static long decode_chunked(const char *in, size_t inlen, char **out)
+{
+    Buf b = {0};
+    size_t pos = 0;
+    while (pos < inlen) {
+        const char *line_end = NULL;
+        for (size_t i = pos; i + 1 < inlen; i++) {
+            if (in[i] == '\r' && in[i + 1] == '\n') {
+                line_end = in + i;
+                break;
+            }
+        }
+        if (!line_end)
+            break;
+        long chunk_len = strtol(in + pos, NULL, 16);
+        pos = (size_t)(line_end - in) + 2;
+        if (chunk_len <= 0)
+            break;
+        if (pos + (size_t)chunk_len > inlen)
+            break;
+        if (buf_append(&b, in + pos, (size_t)chunk_len) != 0) {
+            free(b.data);
+            return -1;
+        }
+        pos += (size_t)chunk_len;
+        if (pos + 2 <= inlen && in[pos] == '\r' && in[pos + 1] == '\n')
+            pos += 2;
+    }
+    *out = b.data;
+    return (long)b.len;
+}
+
+int http_post_json(const char *host, int port, const char *path,
+                    const char *user, const char *pass,
+                    const char *session_id_in,
+                    const char *body, size_t body_len,
+                    HttpResponse *resp, char *err, size_t errlen)
+{
+    memset(resp, 0, sizeof(*resp));
+
+    Buf req = {0};
+    char line[1024];
+
+    int n = snprintf(line, sizeof(line), "POST %s HTTP/1.1\r\n", path);
+    buf_append(&req, line, (size_t)n);
+
+    n = snprintf(line, sizeof(line), "Host: %s:%d\r\n", host, port);
+    buf_append(&req, line, (size_t)n);
+
+    buf_append_str(&req, "User-Agent: transtui/1.0\r\n");
+    buf_append_str(&req, "Content-Type: application/json\r\n");
+    buf_append_str(&req, "Accept: application/json\r\n");
+    buf_append_str(&req, "Connection: close\r\n");
+
+    n = snprintf(line, sizeof(line), "Content-Length: %zu\r\n", body_len);
+    buf_append(&req, line, (size_t)n);
+
+    if (session_id_in && session_id_in[0]) {
+        n = snprintf(line, sizeof(line), "X-Transmission-Session-Id: %s\r\n", session_id_in);
+        buf_append(&req, line, (size_t)n);
+    }
+
+    if (user && user[0]) {
+        char cred[256];
+        n = snprintf(cred, sizeof(cred), "%s:%s", user, pass ? pass : "");
+        char b64[400];
+        long enc = base64_encode((unsigned char *)cred, (size_t)n, b64, sizeof(b64));
+        if (enc > 0) {
+            n = snprintf(line, sizeof(line), "Authorization: Basic %s\r\n", b64);
+            buf_append(&req, line, (size_t)n);
+        }
+    }
+
+    buf_append_str(&req, "\r\n");
+    if (body_len)
+        buf_append(&req, body, body_len);
+
+    int fd = net_connect(host, port, err, errlen);
+    if (fd < 0) {
+        free(req.data);
+        return -1;
+    }
+
+    size_t sent = 0;
+    while (sent < req.len) {
+        ssize_t w = write(fd, req.data + sent, req.len - sent);
+        if (w <= 0) {
+            snprintf(err, errlen, "failed to send HTTP request");
+            free(req.data);
+            close(fd);
+            return -1;
+        }
+        sent += (size_t)w;
+    }
+    free(req.data);
+
+    Buf resb = {0};
+    char chunk[4096];
+    long content_length = -1;
+    int chunked = 0;
+    size_t header_end = 0;
+    int have_headers = 0;
+
+    for (;;) {
+        ssize_t r = read(fd, chunk, sizeof(chunk));
+        if (r < 0) {
+            snprintf(err, errlen, "error reading response");
+            free(resb.data);
+            close(fd);
+            return -1;
+        }
+        if (r == 0)
+            break; /* server closed connection */
+        if (buf_append(&resb, chunk, (size_t)r) != 0) {
+            snprintf(err, errlen, "out of memory");
+            free(resb.data);
+            close(fd);
+            return -1;
+        }
+
+        if (!have_headers) {
+            char *sep = memmem(resb.data, resb.len, "\r\n\r\n", 4);
+            if (sep) {
+                have_headers = 1;
+                header_end = (size_t)(sep - resb.data) + 4;
+                content_length = extract_content_length(resb.data, header_end);
+                chunked = is_chunked(resb.data, header_end);
+            }
+        }
+        if (have_headers && !chunked && content_length >= 0) {
+            if (resb.len - header_end >= (size_t)content_length)
+                break;
+        }
+    }
+    close(fd);
+
+    if (!have_headers) {
+        snprintf(err, errlen, "incomplete HTTP response");
+        free(resb.data);
+        return -1;
+    }
+
+    /* Parse status line: "HTTP/1.1 200 OK" */
+    int status = 0;
+    sscanf(resb.data, "HTTP/%*d.%*d %d", &status);
+    resp->status = status;
+
+    extract_header(resb.data, header_end, "X-Transmission-Session-Id", resp->session_id, sizeof(resp->session_id));
+
+    const char *body_start = resb.data + header_end;
+    size_t avail = resb.len - header_end;
+
+    if (chunked) {
+        char *decoded = NULL;
+        long dlen = decode_chunked(body_start, avail, &decoded);
+        if (dlen < 0) {
+            snprintf(err, errlen, "could not decode chunked response");
+            free(resb.data);
+            return -1;
+        }
+        resp->body = decoded;
+        resp->body_len = (size_t)dlen;
+        free(resb.data);
+    } else {
+        size_t blen = avail;
+        if (content_length >= 0 && (size_t)content_length < blen)
+            blen = (size_t)content_length;
+        char *b = malloc(blen + 1);
+        if (!b) {
+            snprintf(err, errlen, "out of memory");
+            free(resb.data);
+            return -1;
+        }
+        memcpy(b, body_start, blen);
+        b[blen] = '\0';
+        resp->body = b;
+        resp->body_len = blen;
+        free(resb.data);
+    }
+
+    return 0;
+}
+
+void http_response_free(HttpResponse *resp)
+{
+    free(resp->body);
+    resp->body = NULL;
+    resp->body_len = 0;
+}
diff --git a/src/http.h b/src/http.h
new file mode 100644
index 0000000..41d97b3
--- /dev/null
+++ b/src/http.h
@@ -0,0 +1,25 @@
+#ifndef TRANSTUI_HTTP_H
+#define TRANSTUI_HTTP_H
+
+#include <stddef.h>
+
+typedef struct {
+    int status;
+    char *body;
+    size_t body_len;
+    char session_id[128]; /* set if response carried X-Transmission-Session-Id */
+} HttpResponse;
+
+/* Issues a single HTTP/1.1 POST request with a JSON body and reads the full
+ * response. resp is always initialized on return; check resp->status.
+ * Returns 0 if the request/response was exchanged (regardless of HTTP status),
+ * -1 on a network-level failure (err filled with a message). */
+int http_post_json(const char *host, int port, const char *path,
+                    const char *user, const char *pass,
+                    const char *session_id_in,
+                    const char *body, size_t body_len,
+                    HttpResponse *resp, char *err, size_t errlen);
+
+void http_response_free(HttpResponse *resp);
+
+#endif
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..b60a3cd
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,69 @@
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+
+#include "config.h"
+#include "rpc.h"
+#include "torrent.h"
+#include "util.h"
+#include "ui/ui.h"
+
+/* Browsers/file managers hand "Open with" targets a file:// URI (percent-
+ * encoded) rather than a plain path. Strip and decode that so torrent_add()
+ * gets a real filesystem path; magnet:/http(s):// sources pass through
+ * untouched since their own percent-encoding must stay intact. */
+static void resolve_add_source(const char *in, char *out, size_t outsize)
+{
+    if (!str_starts_with(in, "file://")) {
+        snprintf(out, outsize, "%s", in);
+        return;
+    }
+    const char *p = in + 7;
+    size_t o = 0;
+    for (size_t i = 0; p[i] && o + 1 < outsize; i++) {
+        if (p[i] == '%' && isxdigit((unsigned char)p[i + 1]) && isxdigit((unsigned char)p[i + 2])) {
+            char hex[3] = {p[i + 1], p[i + 2], '\0'};
+            out[o++] = (char)strtol(hex, NULL, 16);
+            i += 2;
+        } else {
+            out[o++] = p[i];
+        }
+    }
+    out[o] = '\0';
+}
+
+static int run_add_and_exit(RpcClient *rpc, const char *add_source)
+{
+    char source[1024];
+    resolve_add_source(add_source, source, sizeof(source));
+
+    char err[256];
+    if (torrent_add(rpc, source, NULL, err, sizeof(err)) != 0) {
+        fprintf(stderr, "transtui: could not add torrent: %s\n", err);
+        return 1;
+    }
+    printf("Torrent added: %s\n", source);
+    return 0;
+}
+
+int main(int argc, char **argv)
+{
+    Config cfg;
+    char err[256];
+    if (config_load(&cfg, err, sizeof(err)) != 0) {
+        fprintf(stderr, "transtui: %s\n", err);
+        return 1;
+    }
+
+    char add_source[1024];
+    config_apply_args(&cfg, argc, argv, add_source, sizeof(add_source));
+
+    RpcClient rpc;
+    rpc_init(&rpc, cfg.host, cfg.port, cfg.username[0] ? cfg.username : NULL,
+              cfg.password[0] ? cfg.password : NULL);
+
+    if (add_source[0])
+        return run_add_and_exit(&rpc, add_source);
+
+    return ui_run(&rpc, &cfg);
+}
diff --git a/src/net.c b/src/net.c
new file mode 100644
index 0000000..20703cf
--- /dev/null
+++ b/src/net.c
@@ -0,0 +1,56 @@
+#include "net.h"
+
+#include <errno.h>
+#include <netdb.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <sys/time.h>
+#include <unistd.h>
+
+#define NET_TIMEOUT_SEC 10
+
+int net_connect(const char *host, int port, char *err, size_t errlen)
+{
+    char portstr[16];
+    snprintf(portstr, sizeof(portstr), "%d", port);
+
+    struct addrinfo hints;
+    memset(&hints, 0, sizeof(hints));
+    hints.ai_family = AF_UNSPEC;
+    hints.ai_socktype = SOCK_STREAM;
+
+    struct addrinfo *res = NULL;
+    int rc = getaddrinfo(host, portstr, &hints, &res);
+    if (rc != 0) {
+        snprintf(err, errlen, "cannot resolve %s: %s", host, gai_strerror(rc));
+        return -1;
+    }
+
+    int fd = -1;
+    for (struct addrinfo *ai = res; ai != NULL; ai = ai->ai_next) {
+        fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+        if (fd < 0)
+            continue;
+
+        struct timeval tv;
+        tv.tv_sec = NET_TIMEOUT_SEC;
+        tv.tv_usec = 0;
+        setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
+        setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
+
+        if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
+            break;
+        }
+        close(fd);
+        fd = -1;
+    }
+    freeaddrinfo(res);
+
+    if (fd < 0) {
+        snprintf(err, errlen, "cannot connect to %s:%d: %s", host, port, strerror(errno));
+        return -1;
+    }
+
+    return fd;
+}
diff --git a/src/net.h b/src/net.h
new file mode 100644
index 0000000..0fafe3b
--- /dev/null
+++ b/src/net.h
@@ -0,0 +1,10 @@
+#ifndef TRANSTUI_NET_H
+#define TRANSTUI_NET_H
+
+#include <stddef.h>
+
+/* Opens a TCP connection to host:port with a connect+IO timeout.
+ * Returns a connected socket fd, or -1 on failure (err filled with a message). */
+int net_connect(const char *host, int port, char *err, size_t errlen);
+
+#endif
diff --git a/src/rpc.c b/src/rpc.c
new file mode 100644
index 0000000..1c12c52
--- /dev/null
+++ b/src/rpc.c
@@ -0,0 +1,91 @@
+#include "rpc.h"
+#include "http.h"
+
+#include <stdio.h>
+#include <string.h>
+
+void rpc_init(RpcClient *c, const char *host, int port, const char *user, const char *pass)
+{
+    memset(c, 0, sizeof(*c));
+    snprintf(c->host, sizeof(c->host), "%s", host);
+    c->port = port;
+    if (user)
+        snprintf(c->user, sizeof(c->user), "%s", user);
+    if (pass)
+        snprintf(c->pass, sizeof(c->pass), "%s", pass);
+}
+
+int rpc_call(RpcClient *c, const char *method, cJSON *arguments,
+             cJSON **out_args, char *err, size_t errlen)
+{
+    if (out_args)
+        *out_args = NULL;
+
+    cJSON *root = cJSON_CreateObject();
+    cJSON_AddStringToObject(root, "method", method);
+    if (arguments)
+        cJSON_AddItemToObject(root, "arguments", arguments);
+
+    char *body = cJSON_PrintUnformatted(root);
+    size_t body_len = strlen(body);
+
+    c->last_http_status = 0;
+    int rc = -1;
+    for (int attempt = 0; attempt < 2; attempt++) {
+        HttpResponse resp;
+        int hrc = http_post_json(c->host, c->port, "/transmission/rpc",
+                                  c->user[0] ? c->user : NULL, c->pass,
+                                  c->session_id[0] ? c->session_id : NULL,
+                                  body, body_len, &resp, err, errlen);
+        if (hrc != 0) {
+            rc = -1;
+            break;
+        }
+        c->last_http_status = resp.status;
+
+        if (resp.session_id[0])
+            snprintf(c->session_id, sizeof(c->session_id), "%s", resp.session_id);
+
+        if (resp.status == 409 && attempt == 0) {
+            /* CSRF handshake: retry once now that we have a session id. */
+            http_response_free(&resp);
+            continue;
+        }
+
+        if (resp.status != 200) {
+            snprintf(err, errlen, "HTTP error %d from server", resp.status);
+            http_response_free(&resp);
+            rc = -1;
+            break;
+        }
+
+        cJSON *respjson = cJSON_ParseWithLength(resp.body, resp.body_len);
+        http_response_free(&resp);
+        if (!respjson) {
+            snprintf(err, errlen, "could not parse JSON response");
+            rc = -1;
+            break;
+        }
+
+        cJSON *result = cJSON_GetObjectItemCaseSensitive(respjson, "result");
+        if (!cJSON_IsString(result) || strcmp(result->valuestring, "success") != 0) {
+            snprintf(err, errlen, "RPC error: %s",
+                     cJSON_IsString(result) ? result->valuestring : "unknown error");
+            cJSON_Delete(respjson);
+            rc = -1;
+            break;
+        }
+
+        if (out_args) {
+            cJSON *args = cJSON_DetachItemFromObjectCaseSensitive(respjson, "arguments");
+            *out_args = args;
+        }
+        cJSON_Delete(respjson);
+        rc = 0;
+        break;
+    }
+
+    cJSON_free(body);
+    cJSON_Delete(root);
+    return rc;
+}
diff --git a/src/rpc.h b/src/rpc.h
new file mode 100644
index 0000000..a709bdb
--- /dev/null
+++ b/src/rpc.h
@@ -0,0 +1,32 @@
+#ifndef TRANSTUI_RPC_H
+#define TRANSTUI_RPC_H
+
+#include <stddef.h>
+#include "../third_party/cJSON.h"
+
+typedef struct RpcClient {
+    char host[256];
+    int port;
+    char user[128];
+    char pass[128];
+    char session_id[128];
+    int last_http_status; /* 0 if the last call never got an HTTP response at all */
+} RpcClient;
+
+void rpc_init(RpcClient *c, const char *host, int port, const char *user, const char *pass);
+
+/* Performs a single Transmission JSON-RPC call, transparently handling the
+ * X-Transmission-Session-Id CSRF handshake (one retry on HTTP 409).
+ *
+ * `arguments` ownership is transferred to this call (it will be freed
+ * internally) - pass NULL if the method takes no arguments.
+ *
+ * On success returns 0 and, if out_args is non-NULL, sets *out_args to the
+ * "arguments" object of the response (caller must cJSON_Delete it), or NULL
+ * if the response had none.
+ *
+ * On failure returns -1 and fills err with a human-readable message. */
+int rpc_call(RpcClient *c, const char *method, cJSON *arguments,
+             cJSON **out_args, char *err, size_t errlen);
+
+#endif
diff --git a/src/torrent.c b/src/torrent.c
new file mode 100644
index 0000000..1fcd0dc
--- /dev/null
+++ b/src/torrent.c
@@ -0,0 +1,348 @@
+#include "torrent.h"
+#include "util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+/* ---- small cJSON accessor helpers ---------------------------------- */
+
+static int64_t get_i64(const cJSON *obj, const char *key, int64_t def)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
+    if (!v || !cJSON_IsNumber(v))
+        return def;
+    return (int64_t)v->valuedouble;
+}
+
+static int get_int(const cJSON *obj, const char *key, int def)
+{
+    return (int)get_i64(obj, key, def);
+}
+
+static double get_dbl(const cJSON *obj, const char *key, double def)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
+    if (!v || !cJSON_IsNumber(v))
+        return def;
+    return v->valuedouble;
+}
+
+static void get_str(const cJSON *obj, const char *key, char *buf, size_t n)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(obj, key);
+    if (v && cJSON_IsString(v))
+        snprintf(buf, n, "%s", v->valuestring);
+    else
+        buf[0] = '\0';
+}
+
+static cJSON *field_array(const char **names, size_t n)
+{
+    cJSON *arr = cJSON_CreateArray();
+    for (size_t i = 0; i < n; i++)
+        cJSON_AddItemToArray(arr, cJSON_CreateString(names[i]));
+    return arr;
+}
+
+static cJSON *id_array(const int *ids, size_t n)
+{
+    cJSON *arr = cJSON_CreateArray();
+    for (size_t i = 0; i < n; i++)
+        cJSON_AddItemToArray(arr, cJSON_CreateNumber(ids[i]));
+    return arr;
+}
+
+static void parse_summary(const cJSON *t, Torrent *out)
+{
+    memset(out, 0, sizeof(*out));
+    out->id = get_int(t, "id", -1);
+    get_str(t, "hashString", out->hash, sizeof(out->hash));
+    get_str(t, "name", out->name, sizeof(out->name));
+    out->total_size = get_i64(t, "totalSize", 0);
+    out->left_until_done = get_i64(t, "leftUntilDone", 0);
+    out->percent_done = get_dbl(t, "percentDone", 0.0);
+    out->status = get_int(t, "status", TR_STATUS_STOPPED);
+    out->rate_download = get_i64(t, "rateDownload", 0);
+    out->rate_upload = get_i64(t, "rateUpload", 0);
+    out->eta = get_int(t, "eta", -1);
+    out->upload_ratio = get_dbl(t, "uploadRatio", 0.0);
+    out->error = get_int(t, "error", 0);
+    get_str(t, "errorString", out->error_string, sizeof(out->error_string));
+    get_str(t, "downloadDir", out->download_dir, sizeof(out->download_dir));
+    out->peers_connected = get_int(t, "peersConnected", 0);
+    out->peers_sending_to_us = get_int(t, "peersSendingToUs", 0);
+    out->peers_getting_from_us = get_int(t, "peersGettingFromUs", 0);
+}
+
+static const char *SUMMARY_FIELDS[] = {
+    "id", "name", "hashString", "totalSize", "leftUntilDone", "percentDone",
+    "status", "rateDownload", "rateUpload", "eta", "uploadRatio", "error",
+    "errorString", "downloadDir", "peersConnected", "peersSendingToUs",
+    "peersGettingFromUs",
+};
+
+void torrent_list_init(TorrentList *list)
+{
+    memset(list, 0, sizeof(*list));
+}
+
+void torrent_list_free(TorrentList *list)
+{
+    free(list->items);
+    memset(list, 0, sizeof(*list));
+}
+
+int torrent_list_refresh(RpcClient *rpc, TorrentList *list, char *err, size_t errlen)
+{
+    cJSON *args = cJSON_CreateObject();
+    cJSON_AddItemToObject(args, "fields",
+                           field_array(SUMMARY_FIELDS, sizeof(SUMMARY_FIELDS) / sizeof(SUMMARY_FIELDS[0])));
+
+    cJSON *out = NULL;
+    if (rpc_call(rpc, "torrent-get", args, &out, err, errlen) != 0)
+        return -1;
+
+    cJSON *torrents = out ? cJSON_GetObjectItemCaseSensitive(out, "torrents") : NULL;
+    if (!torrents || !cJSON_IsArray(torrents)) {
+        snprintf(err, errlen, "unexpected response from torrent-get");
+        cJSON_Delete(out);
+        return -1;
+    }
+
+    /* Remember which torrent ids were selected so we can restore the flag
+     * after repopulating the array (list order can shift between polls). */
+    size_t old_selected_count = 0;
+    for (size_t i = 0; i < list->count; i++)
+        if (list->items[i].selected)
+            old_selected_count++;
+    int *old_selected_ids = NULL;
+    if (old_selected_count) {
+        old_selected_ids = malloc(old_selected_count * sizeof(int));
+        size_t j = 0;
+        for (size_t i = 0; i < list->count; i++)
+            if (list->items[i].selected)
+                old_selected_ids[j++] = list->items[i].id;
+    }
+
+    int count = cJSON_GetArraySize(torrents);
+    if ((size_t)count > list->capacity) {
+        size_t ncap = (size_t)count;
+        Torrent *ni = realloc(list->items, ncap * sizeof(Torrent));
+        if (!ni) {
+            snprintf(err, errlen, "out of memory");
+            free(old_selected_ids);
+            cJSON_Delete(out);
+            return -1;
+        }
+        list->items = ni;
+        list->capacity = ncap;
+    }
+
+    for (int i = 0; i < count; i++)
+        parse_summary(cJSON_GetArrayItem(torrents, i), &list->items[i]);
+    list->count = (size_t)count;
+
+    for (size_t s = 0; s < old_selected_count; s++) {
+        for (size_t i = 0; i < list->count; i++) {
+            if (list->items[i].id == old_selected_ids[s]) {
+                list->items[i].selected = 1;
+                break;
+            }
+        }
+    }
+    free(old_selected_ids);
+
+    cJSON_Delete(out);
+    return 0;
+}
+
+void torrent_detail_free(TorrentDetail *d)
+{
+    free(d->files);
+    free(d->peers);
+    free(d->trackers);
+    memset(d, 0, sizeof(*d));
+}
+
+int torrent_get_detail(RpcClient *rpc, int id, TorrentDetail *out, char *err, size_t errlen)
+{
+    static const char *fields[] = {
+        "id", "name", "hashString", "totalSize", "leftUntilDone", "percentDone",
+        "status", "rateDownload", "rateUpload", "eta", "uploadRatio", "error",
+        "errorString", "downloadDir", "peersConnected", "peersSendingToUs",
+        "peersGettingFromUs", "dateAdded", "dateCreated", "doneDate",
+        "downloadLimit", "downloadLimited", "uploadLimit", "uploadLimited",
+        "files", "fileStats", "peers", "trackerStats",
+    };
+
+    cJSON *args = cJSON_CreateObject();
+    int idarr[1] = {id};
+    cJSON_AddItemToObject(args, "ids", id_array(idarr, 1));
+    cJSON_AddItemToObject(args, "fields", field_array(fields, sizeof(fields) / sizeof(fields[0])));
+
+    cJSON *resp = NULL;
+    if (rpc_call(rpc, "torrent-get", args, &resp, err, errlen) != 0)
+        return -1;
+
+    cJSON *torrents = resp ? cJSON_GetObjectItemCaseSensitive(resp, "torrents") : NULL;
+    if (!torrents || !cJSON_IsArray(torrents) || cJSON_GetArraySize(torrents) < 1) {
+        snprintf(err, errlen, "torrent not found");
+        cJSON_Delete(resp);
+        return -1;
+    }
+    cJSON *t = cJSON_GetArrayItem(torrents, 0);
+
+    memset(out, 0, sizeof(*out));
+    parse_summary(t, &out->info);
+    out->date_added = (long)get_i64(t, "dateAdded", 0);
+    out->date_created = (long)get_i64(t, "dateCreated", 0);
+    out->done_date = (long)get_i64(t, "doneDate", 0);
+    out->download_limit = get_i64(t, "downloadLimit", 0);
+    out->download_limited = get_int(t, "downloadLimited", 0);
+    out->upload_limit = get_i64(t, "uploadLimit", 0);
+    out->upload_limited = get_int(t, "uploadLimited", 0);
+
+    cJSON *files = cJSON_GetObjectItemCaseSensitive(t, "files");
+    cJSON *filestats = cJSON_GetObjectItemCaseSensitive(t, "fileStats");
+    if (files && cJSON_IsArray(files)) {
+        int n = cJSON_GetArraySize(files);
+        out->files = calloc((size_t)n, sizeof(TorrentFile));
+        out->file_count = (size_t)n;
+        for (int i = 0; i < n; i++) {
+            cJSON *f = cJSON_GetArrayItem(files, i);
+            TorrentFile *tf = &out->files[i];
+            get_str(f, "name", tf->name, sizeof(tf->name));
+            tf->length = get_i64(f, "length", 0);
+            tf->bytes_completed = get_i64(f, "bytesCompleted", 0);
+            tf->wanted = 1;
+            tf->priority = 0;
+            if (filestats && cJSON_IsArray(filestats) && i < cJSON_GetArraySize(filestats)) {
+                cJSON *fs = cJSON_GetArrayItem(filestats, i);
+                tf->wanted = get_int(fs, "wanted", 1);
+                tf->priority = get_int(fs, "priority", 0);
+            }
+        }
+    }
+
+    cJSON *peers = cJSON_GetObjectItemCaseSensitive(t, "peers");
+    if (peers && cJSON_IsArray(peers)) {
+        int n = cJSON_GetArraySize(peers);
+        out->peers = calloc((size_t)n, sizeof(TorrentPeer));
+        out->peer_count = (size_t)n;
+        for (int i = 0; i < n; i++) {
+            cJSON *p = cJSON_GetArrayItem(peers, i);
+            TorrentPeer *tp = &out->peers[i];
+            get_str(p, "address", tp->address, sizeof(tp->address));
+            get_str(p, "clientName", tp->client_name, sizeof(tp->client_name));
+            get_str(p, "flagStr", tp->flags, sizeof(tp->flags));
+            tp->progress = get_dbl(p, "progress", 0.0);
+            tp->rate_to_client = get_i64(p, "rateToClient", 0);
+            tp->rate_to_peer = get_i64(p, "rateToPeer", 0);
+        }
+    }
+
+    cJSON *trackers = cJSON_GetObjectItemCaseSensitive(t, "trackerStats");
+    if (trackers && cJSON_IsArray(trackers)) {
+        int n = cJSON_GetArraySize(trackers);
+        out->trackers = calloc((size_t)n, sizeof(TorrentTracker));
+        out->tracker_count = (size_t)n;
+        for (int i = 0; i < n; i++) {
+            cJSON *tr = cJSON_GetArrayItem(trackers, i);
+            TorrentTracker *tt = &out->trackers[i];
+            get_str(tr, "announce", tt->announce, sizeof(tt->announce));
+            tt->tier = get_int(tr, "tier", 0);
+            get_str(tr, "lastAnnounceResult", tt->last_announce_result, sizeof(tt->last_announce_result));
+            tt->seeder_count = get_int(tr, "seederCount", -1);
+            tt->leecher_count = get_int(tr, "leecherCount", -1);
+        }
+    }
+
+    cJSON_Delete(resp);
+    return 0;
+}
+
+int torrent_action(RpcClient *rpc, const char *method, const int *ids, size_t n, char *err, size_t errlen)
+{
+    cJSON *args = cJSON_CreateObject();
+    cJSON_AddItemToObject(args, "ids", id_array(ids, n));
+    return rpc_call(rpc, method, args, NULL, err, errlen);
+}
+
+int torrent_remove(RpcClient *rpc, const int *ids, size_t n, int delete_data, char *err, size_t errlen)
+{
+    cJSON *args = cJSON_CreateObject();
+    cJSON_AddItemToObject(args, "ids", id_array(ids, n));
+    cJSON_AddBoolToObject(args, "delete-local-data", delete_data);
+    return rpc_call(rpc, "torrent-remove", args, NULL, err, errlen);
+}
+
+int torrent_add(RpcClient *rpc, const char *source, const char *download_dir, char *err, size_t errlen)
+{
+    cJSON *args = cJSON_CreateObject();
+
+    if (str_starts_with(source, "magnet:") || str_starts_with(source, "http://") ||
+        str_starts_with(source, "https://")) {
+        cJSON_AddStringToObject(args, "filename", source);
+    } else {
+        size_t flen = 0;
+        unsigned char *data = read_file_all(source, &flen);
+        if (!data) {
+            snprintf(err, errlen, "could not read file: %s", source);
+            cJSON_Delete(args);
+            return -1;
+        }
+        size_t b64size = base64_encoded_size(flen) + 1;
+        char *b64 = malloc(b64size);
+        if (!b64 || base64_encode(data, flen, b64, b64size) < 0) {
+            snprintf(err, errlen, "could not base64-encode the file");
+            free(data);
+            free(b64);
+            cJSON_Delete(args);
+            return -1;
+        }
+        free(data);
+        cJSON_AddStringToObject(args, "metainfo", b64);
+        free(b64);
+    }
+
+    if (download_dir && download_dir[0])
+        cJSON_AddStringToObject(args, "download-dir", download_dir);
+
+    return rpc_call(rpc, "torrent-add", args, NULL, err, errlen);
+}
+
+int torrent_set_speed_limit(RpcClient *rpc, const int *ids, size_t n,
+                             int64_t down_kbps, int down_enabled,
+                             int64_t up_kbps, int up_enabled,
+                             char *err, size_t errlen)
+{
+    cJSON *args = cJSON_CreateObject();
+    cJSON_AddItemToObject(args, "ids", id_array(ids, n));
+    cJSON_AddNumberToObject(args, "downloadLimit", (double)down_kbps);
+    cJSON_AddBoolToObject(args, "downloadLimited", down_enabled);
+    cJSON_AddNumberToObject(args, "uploadLimit", (double)up_kbps);
+    cJSON_AddBoolToObject(args, "uploadLimited", up_enabled);
+    return rpc_call(rpc, "torrent-set", args, NULL, err, errlen);
+}
+
+int torrent_set_file_wanted(RpcClient *rpc, int id, const int *file_indices, size_t n, int wanted,
+                             char *err, size_t errlen)
+{
+    cJSON *args = cJSON_CreateObject();
+    int idarr[1] = {id};
+    cJSON_AddItemToObject(args, "ids", id_array(idarr, 1));
+    cJSON_AddItemToObject(args, wanted ? "files-wanted" : "files-unwanted", id_array(file_indices, n));
+    return rpc_call(rpc, "torrent-set", args, NULL, err, errlen);
+}
+
+int torrent_set_file_priority(RpcClient *rpc, int id, const int *file_indices, size_t n, int priority,
+                               char *err, size_t errlen)
+{
+    const char *key = priority > 0 ? "priority-high" : (priority < 0 ? "priority-low" : "priority-normal");
+    cJSON *args = cJSON_CreateObject();
+    int idarr[1] = {id};
+    cJSON_AddItemToObject(args, "ids", id_array(idarr, 1));
+    cJSON_AddItemToObject(args, key, id_array(file_indices, n));
+    return rpc_call(rpc, "torrent-set", args, NULL, err, errlen);
+}
diff --git a/src/torrent.h b/src/torrent.h
new file mode 100644
index 0000000..361fc4d
--- /dev/null
+++ b/src/torrent.h
@@ -0,0 +1,117 @@
+#ifndef TRANSTUI_TORRENT_H
+#define TRANSTUI_TORRENT_H
+
+#include <stddef.h>
+#include <stdint.h>
+#include "rpc.h"
+
+enum {
+    TR_STATUS_STOPPED = 0,
+    TR_STATUS_CHECK_WAIT = 1,
+    TR_STATUS_CHECK = 2,
+    TR_STATUS_DOWNLOAD_WAIT = 3,
+    TR_STATUS_DOWNLOAD = 4,
+    TR_STATUS_SEED_WAIT = 5,
+    TR_STATUS_SEED = 6,
+};
+
+typedef struct {
+    int id;
+    char hash[41];
+    char name[256];
+    int64_t total_size;
+    int64_t left_until_done;
+    double percent_done;
+    int status;
+    int64_t rate_download;
+    int64_t rate_upload;
+    int eta;
+    double upload_ratio;
+    int error;
+    char error_string[256];
+    char download_dir[512];
+    int peers_connected;
+    int peers_sending_to_us;
+    int peers_getting_from_us;
+    int selected; /* multi-select flag, UI-owned */
+} Torrent;
+
+typedef struct {
+    Torrent *items;
+    size_t count;
+    size_t capacity;
+} TorrentList;
+
+typedef struct {
+    char name[512];
+    int64_t length;
+    int64_t bytes_completed;
+    int wanted;
+    int priority; /* -1 low, 0 normal, 1 high */
+} TorrentFile;
+
+typedef struct {
+    char address[64];
+    char client_name[128];
+    double progress;
+    int64_t rate_to_client;
+    int64_t rate_to_peer;
+    char flags[16];
+} TorrentPeer;
+
+typedef struct {
+    char announce[256];
+    int tier;
+    char last_announce_result[256];
+    int seeder_count;
+    int leecher_count;
+} TorrentTracker;
+
+typedef struct {
+    Torrent info;
+    long date_added;
+    long date_created;
+    long done_date;
+    int64_t download_limit;
+    int download_limited;
+    int64_t upload_limit;
+    int upload_limited;
+
+    TorrentFile *files;
+    size_t file_count;
+    TorrentPeer *peers;
+    size_t peer_count;
+    TorrentTracker *trackers;
+    size_t tracker_count;
+} TorrentDetail;
+
+void torrent_list_init(TorrentList *list);
+void torrent_list_free(TorrentList *list);
+/* Fetches the summary field set for all torrents and refreshes `list` in
+ * place, reusing the existing allocation where possible. */
+int torrent_list_refresh(RpcClient *rpc, TorrentList *list, char *err, size_t errlen);
+
+void torrent_detail_free(TorrentDetail *d);
+int torrent_get_detail(RpcClient *rpc, int id, TorrentDetail *out, char *err, size_t errlen);
+
+/* method is one of: torrent-start, torrent-start-now, torrent-stop,
+ * torrent-verify, torrent-reannounce */
+int torrent_action(RpcClient *rpc, const char *method, const int *ids, size_t n, char *err, size_t errlen);
+int torrent_remove(RpcClient *rpc, const int *ids, size_t n, int delete_data, char *err, size_t errlen);
+
+/* source: a magnet: URI, an http(s):// URL to a .torrent, or a local file
+ * path (read and sent as base64 metainfo, so it works against remote daemons
+ * too). download_dir may be NULL to use the daemon's default. */
+int torrent_add(RpcClient *rpc, const char *source, const char *download_dir, char *err, size_t errlen);
+
+int torrent_set_speed_limit(RpcClient *rpc, const int *ids, size_t n,
+                             int64_t down_kbps, int down_enabled,
+                             int64_t up_kbps, int up_enabled,
+                             char *err, size_t errlen);
+
+int torrent_set_file_wanted(RpcClient *rpc, int id, const int *file_indices, size_t n, int wanted,
+                             char *err, size_t errlen);
+int torrent_set_file_priority(RpcClient *rpc, int id, const int *file_indices, size_t n, int priority,
+                               char *err, size_t errlen);
+
+#endif
diff --git a/src/ui/ui.c b/src/ui/ui.c
new file mode 100644
index 0000000..fd2bb98
--- /dev/null
+++ b/src/ui/ui.c
@@ -0,0 +1,497 @@
+#include "ui.h"
+#include "../daemon.h"
+#include "../util.h"
+
+#include <curses.h>
+#include <locale.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/ioctl.h>
+#include <time.h>
+#include <unistd.h>
+#include <wchar.h>
+
+void ui_init_colors(void)
+{
+    if (!has_colors())
+        return;
+    start_color();
+    use_default_colors();
+    init_pair(CP_DEFAULT, -1, -1);
+    init_pair(CP_HEADER, COLOR_BLACK, COLOR_CYAN);
+    init_pair(CP_SELROW, COLOR_BLACK, COLOR_WHITE);
+    init_pair(CP_SEEDING, COLOR_GREEN, -1);
+    init_pair(CP_DOWNLOADING, COLOR_CYAN, -1);
+    init_pair(CP_PAUSED, COLOR_YELLOW, -1);
+    init_pair(CP_ERROR, COLOR_RED, -1);
+    init_pair(CP_CHECK, COLOR_MAGENTA, -1);
+    init_pair(CP_BORDER, COLOR_CYAN, -1);
+    init_pair(CP_ACCENT, COLOR_CYAN, -1);
+    init_pair(CP_SPLASH_WHITE, COLOR_WHITE, -1);
+}
+
+/* ---- shared panel/box/footer drawing ---------------------------------- */
+
+void ui_box(WINDOW *win, int h, int w)
+{
+    if (h < 2 || w < 2)
+        return;
+    wattron(win, COLOR_PAIR(CP_BORDER));
+    mvwaddwstr(win, 0, 0, L"╭");
+    for (int x = 1; x < w - 1; x++)
+        mvwaddwstr(win, 0, x, L"─");
+    mvwaddwstr(win, 0, w - 1, L"╮");
+    for (int y = 1; y < h - 1; y++) {
+        mvwaddwstr(win, y, 0, L"│");
+        mvwaddwstr(win, y, w - 1, L"│");
+    }
+    mvwaddwstr(win, h - 1, 0, L"╰");
+    for (int x = 1; x < w - 1; x++)
+        mvwaddwstr(win, h - 1, x, L"─");
+    mvwaddwstr(win, h - 1, w - 1, L"╯");
+    wattroff(win, COLOR_PAIR(CP_BORDER));
+}
+
+void ui_box_title(WINDOW *win, int w, const char *title)
+{
+    int len = (int)strlen(title);
+    int x = 2;
+    if (x + len + 2 > w)
+        len = w - x - 2;
+    if (len <= 0)
+        return;
+    wattron(win, COLOR_PAIR(CP_ACCENT) | A_BOLD);
+    mvwprintw(win, 0, x, " %.*s ", len, title);
+    wattroff(win, COLOR_PAIR(CP_ACCENT) | A_BOLD);
+}
+
+void ui_draw_footer(const KeyHint *hints, size_t n, int nrows)
+{
+    if (nrows > 2)
+        nrows = 2;
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+
+    for (int r = 0; r < nrows; r++) {
+        move(rows - nrows + r, 0);
+        clrtoeol();
+    }
+
+    int row = 0, x = 1;
+    for (size_t i = 0; i < n && row < nrows; i++) {
+        int chip_w = (int)(strlen(hints[i].key) + strlen(hints[i].desc)) + 3;
+        if (x + chip_w >= cols) {
+            row++;
+            x = 1;
+            if (row >= nrows)
+                break;
+        }
+        attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
+        mvprintw(rows - nrows + row, x, " %s ", hints[i].key);
+        attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
+        x += (int)strlen(hints[i].key) + 2;
+        mvprintw(rows - nrows + row, x, " %s ", hints[i].desc);
+        x += (int)strlen(hints[i].desc) + 2;
+    }
+}
+
+int ui_color_for_status(int status)
+{
+    switch (status) {
+    case TR_STATUS_SEED:
+    case TR_STATUS_SEED_WAIT:
+        return CP_SEEDING;
+    case TR_STATUS_DOWNLOAD:
+    case TR_STATUS_DOWNLOAD_WAIT:
+        return CP_DOWNLOADING;
+    case TR_STATUS_CHECK:
+    case TR_STATUS_CHECK_WAIT:
+        return CP_CHECK;
+    case TR_STATUS_STOPPED:
+    default:
+        return CP_PAUSED;
+    }
+}
+
+void ui_set_status(AppState *st, const char *fmt, ...)
+{
+    va_list ap;
+    va_start(ap, fmt);
+    vsnprintf(st->status_msg, sizeof(st->status_msg), fmt, ap);
+    va_end(ap);
+    st->status_msg_until = time(NULL) + 5;
+}
+
+int ui_refresh_list(AppState *st)
+{
+    char err[256];
+    /* Always stamp last_poll, even on failure - otherwise a disconnected
+     * daemon leaves it at its zero-initialized value forever, and the main
+     * loop's "elapsed >= poll_interval_ms" check is true on every ~200ms
+     * tick, hammering connect() in a tight retry loop instead of backing
+     * off to the configured poll interval. */
+    clock_gettime(CLOCK_MONOTONIC, &st->last_poll);
+    if (torrent_list_refresh(st->rpc, &st->list, err, sizeof(err)) != 0) {
+        ui_set_status(st, "Connection error: %s", err);
+        return -1;
+    }
+    return 0;
+}
+
+/* --- sorting/filtering ------------------------------------------------ */
+
+static AppState *g_sort_ctx;
+
+static int passes_filter(const Torrent *t, FilterMode f)
+{
+    switch (f) {
+    case FILTER_DOWNLOADING:
+        return t->status == TR_STATUS_DOWNLOAD || t->status == TR_STATUS_DOWNLOAD_WAIT;
+    case FILTER_UPLOADING:
+        return t->status == TR_STATUS_SEED || t->status == TR_STATUS_SEED_WAIT;
+    case FILTER_PAUSED:
+        return t->status == TR_STATUS_STOPPED;
+    case FILTER_COMPLETED:
+        return t->percent_done >= 1.0;
+    case FILTER_ALL:
+    default:
+        return 1;
+    }
+}
+
+static int cmp_order(const void *pa, const void *pb)
+{
+    const AppState *st = g_sort_ctx;
+    const Torrent *a = &st->list.items[*(const int *)pa];
+    const Torrent *b = &st->list.items[*(const int *)pb];
+    int r = 0;
+    switch (st->sort_col) {
+    case SORT_NAME:
+        r = strcasecmp(a->name, b->name);
+        break;
+    case SORT_SIZE:
+        r = (a->total_size > b->total_size) - (a->total_size < b->total_size);
+        break;
+    case SORT_PROGRESS:
+        r = (a->percent_done > b->percent_done) - (a->percent_done < b->percent_done);
+        break;
+    case SORT_STATUS:
+        r = a->status - b->status;
+        break;
+    case SORT_DOWN:
+        r = (a->rate_download > b->rate_download) - (a->rate_download < b->rate_download);
+        break;
+    case SORT_UP:
+        r = (a->rate_upload > b->rate_upload) - (a->rate_upload < b->rate_upload);
+        break;
+    case SORT_RATIO:
+        r = (a->upload_ratio > b->upload_ratio) - (a->upload_ratio < b->upload_ratio);
+        break;
+    case SORT_ETA:
+        r = a->eta - b->eta;
+        break;
+    default:
+        break;
+    }
+    if (r == 0)
+        r = strcasecmp(a->name, b->name);
+    return st->sort_desc ? -r : r;
+}
+
+void ui_rebuild_order(AppState *st)
+{
+    if (st->order_capacity < st->list.count) {
+        size_t ncap = st->list.count;
+        int *no = realloc(st->order, ncap * sizeof(int));
+        if (!no)
+            return;
+        st->order = no;
+        st->order_capacity = ncap;
+    }
+
+    size_t n = 0;
+    for (size_t i = 0; i < st->list.count; i++) {
+        const Torrent *t = &st->list.items[i];
+        if (!passes_filter(t, st->filter))
+            continue;
+        if (st->search[0] && !str_ci_contains(t->name, st->search))
+            continue;
+        st->order[n++] = (int)i;
+    }
+    st->order_count = n;
+
+    g_sort_ctx = st;
+    if (n > 1)
+        qsort(st->order, n, sizeof(int), cmp_order);
+
+    if (st->cursor >= (int)st->order_count)
+        st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
+    if (st->cursor < 0)
+        st->cursor = 0;
+}
+
+void ui_open_detail(AppState *st, int torrent_id)
+{
+    st->detail_id = torrent_id;
+    st->detail_loaded = 0;
+    st->detail_tab = DETAIL_GENERAL;
+    st->detail_cursor = 0;
+    st->view = VIEW_DETAIL;
+}
+
+void ui_close_detail(AppState *st)
+{
+    if (st->detail_loaded)
+        torrent_detail_free(&st->detail);
+    st->detail_loaded = 0;
+    st->view = VIEW_LIST;
+}
+
+/* --- main loop ---------------------------------------------------------- */
+
+static long elapsed_ms(const struct timespec *since)
+{
+    struct timespec now;
+    clock_gettime(CLOCK_MONOTONIC, &now);
+    return (now.tv_sec - since->tv_sec) * 1000 + (now.tv_nsec - since->tv_nsec) / 1000000;
+}
+
+/* Called once at startup if the initial connection failed. If the daemon is
+ * supposed to be on this machine, offer to launch it and give it a few
+ * seconds to come up before continuing into the normal UI either way. */
+static void offer_daemon_start(AppState *st)
+{
+    if (!daemon_host_is_local(st->cfg->host))
+        return;
+
+    char msg[512];
+    snprintf(msg, sizeof(msg), "No daemon is responding at %s:%d. Start transmission-daemon?",
+              st->cfg->host, st->cfg->port);
+    if (!ui_confirm("No daemon found", msg))
+        return;
+
+    char err[256];
+    if (daemon_start(err, sizeof(err)) != 0) {
+        ui_message("Could not start daemon", err);
+        return;
+    }
+
+    ui_set_status(st, "Started transmission-daemon, connecting...");
+    ui_list_render(st);
+
+    for (int attempt = 0; attempt < 10; attempt++) {
+        usleep(400000);
+        if (ui_refresh_list(st) == 0)
+            return;
+    }
+    ui_set_status(st, "Started the daemon but could not connect yet - try again with 'r'");
+}
+
+/* Called once at startup if the daemon answered but rejected us with 401 -
+ * it's up and reachable, we just don't know its RPC password (Debian's
+ * package generates a random one that's never shown in plaintext). Offers
+ * to either disable RPC auth or set a known password, via `sudo`. */
+static void offer_fix_auth(AppState *st)
+{
+    if (!daemon_host_is_local(st->cfg->host))
+        return;
+
+    char msg[512];
+    snprintf(msg, sizeof(msg),
+              "The daemon at %s:%d is responding but requires a password we don't know. "
+              "Fix it now (requires sudo)?",
+              st->cfg->host, st->cfg->port);
+    if (!ui_confirm("Login required", msg))
+        return;
+
+    int disable = !ui_confirm("Authentication", "Set a custom password? (No = disable password protection entirely)");
+
+    char pass[128] = "";
+    if (!disable) {
+        if (!ui_prompt("New RPC password (username will be 'transmission')", "", pass, sizeof(pass)) ||
+            !pass[0])
+            return;
+    }
+
+    ui_set_status(st, "Configuring transmission-daemon - check the terminal for a sudo prompt...");
+    ui_list_render(st);
+
+    /* sudo needs to prompt on the real terminal, which curses currently has
+     * in raw/alternate-screen mode - step out of its way and back in. */
+    def_prog_mode();
+    endwin();
+    char err[256];
+    int rc = daemon_fix_auth("transmission", disable ? NULL : pass, err, sizeof(err));
+    reset_prog_mode();
+    refresh();
+
+    if (rc != 0) {
+        ui_message("Could not configure the daemon", err);
+        return;
+    }
+
+    if (!disable) {
+        snprintf(st->cfg->username, sizeof(st->cfg->username), "transmission");
+        snprintf(st->cfg->password, sizeof(st->cfg->password), "%s", pass);
+    } else {
+        st->cfg->username[0] = '\0';
+        st->cfg->password[0] = '\0';
+    }
+    snprintf(st->rpc->user, sizeof(st->rpc->user), "%s", st->cfg->username);
+    snprintf(st->rpc->pass, sizeof(st->rpc->pass), "%s", st->cfg->password);
+    st->rpc->session_id[0] = '\0';
+    config_save(st->cfg, err, sizeof(err)); /* best effort */
+
+    ui_set_status(st, "Done, connecting...");
+    ui_list_render(st);
+
+    for (int attempt = 0; attempt < 10; attempt++) {
+        usleep(400000);
+        if (ui_refresh_list(st) == 0)
+            return;
+    }
+    ui_set_status(st, "Could not connect yet - try again with 'r'");
+}
+
+void ui_offer_reconnect_help(AppState *st)
+{
+    if (st->rpc->last_http_status == 401)
+        offer_fix_auth(st);
+    else
+        offer_daemon_start(st);
+}
+
+int ui_run(RpcClient *rpc, Config *cfg)
+{
+    AppState st;
+    memset(&st, 0, sizeof(st));
+    st.rpc = rpc;
+    st.cfg = cfg;
+    st.running = 1;
+    st.sort_col = SORT_NAME;
+    st.focus = FOCUS_LIST;
+    torrent_list_init(&st.list);
+
+    setlocale(LC_ALL, "");
+    /* JSON numbers are always '.'-decimal regardless of locale (RFC 8259);
+     * cJSON's number parser uses strtod() under the active LC_NUMERIC, so a
+     * locale with a comma decimal point (e.g. sv_SE) silently truncates
+     * every "1.0"-style value and desyncs the parser. Keep LC_NUMERIC at
+     * "C" while leaving the rest of the locale (LC_CTYPE etc.) localized
+     * for correct UTF-8 rendering. */
+    setlocale(LC_NUMERIC, "C");
+#ifdef NCURSES_VERSION
+    /* ncurses defaults to a 1s ESCDELAY to disambiguate a lone Esc from the
+     * start of a function-key sequence, which makes Esc feel unresponsive. */
+    set_escdelay(25);
+#endif
+    initscr();
+    /* ncurses can size stdscr from stale $COLUMNS/$LINES env vars instead
+     * of the terminal's actual dimensions (common after a resize the shell
+     * never got to re-export, or in some multiplexer/SSH setups) - centered
+     * popups then get positioned for a screen that doesn't match reality
+     * and end up partly or fully off-screen. Force a re-sync from the
+     * kernel's own idea of the window size right after initscr(). */
+    {
+        struct winsize ws;
+        if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == 0 && ws.ws_row > 0 && ws.ws_col > 0)
+            resizeterm(ws.ws_row, ws.ws_col);
+    }
+    cbreak();
+    noecho();
+    keypad(stdscr, TRUE);
+    curs_set(0);
+    ui_init_colors();
+
+    int pending_key = ERR;
+    if (cfg->show_splash)
+        pending_key = ui_splash_show();
+
+    int poll_tick_ms = 200; /* how often we wake up to check the poll interval / redraw */
+    timeout(poll_tick_ms);
+
+    if (ui_refresh_list(&st) != 0)
+        ui_offer_reconnect_help(&st);
+    else
+        ui_set_status(&st, "Connected to %s:%d", cfg->host, cfg->port);
+
+    /* Only redraw when something could actually have changed (a key was
+     * handled, or a periodic RPC refresh landed) - not unconditionally on
+     * every ~200ms poll tick. Redrawing (and re-sending a full screen's
+     * worth of escape sequences) when nothing changed is wasted work, and
+     * on slower links/terminals the constant churn can show up as visible
+     * flicker on modal popups the user is just sitting and looking at. */
+    int need_render = 1;
+    while (st.running) {
+        if (need_render) {
+            ui_rebuild_order(&st);
+            switch (st.view) {
+            case VIEW_LIST:
+                ui_list_render(&st);
+                break;
+            case VIEW_DETAIL:
+                ui_detail_render(&st);
+                break;
+            case VIEW_SETTINGS:
+                ui_settings_render(&st);
+                break;
+            case VIEW_HELP:
+                ui_help_render(&st);
+                break;
+            }
+            need_render = 0;
+        }
+
+        /* Whatever key dismissed the splash is a real command the user
+         * meant for the app (e.g. 'g' for settings) - act on it instead of
+         * silently swallowing it, before falling back to reading new input. */
+        int ch;
+        if (pending_key != ERR) {
+            ch = pending_key;
+            pending_key = ERR;
+        } else {
+            ch = getch();
+        }
+        if (ch == ERR) {
+            if (elapsed_ms(&st.last_poll) >= cfg->poll_interval_ms || st.need_poll_now) {
+                st.need_poll_now = 0;
+                ui_refresh_list(&st);
+                if (st.view == VIEW_DETAIL && st.detail_loaded) {
+                    char err[256];
+                    torrent_detail_free(&st.detail);
+                    st.detail_loaded =
+                        torrent_get_detail(st.rpc, st.detail_id, &st.detail, err, sizeof(err)) == 0;
+                }
+                need_render = 1;
+            }
+            continue;
+        }
+
+        need_render = 1;
+        switch (st.view) {
+        case VIEW_LIST:
+            ui_list_handle_key(&st, ch);
+            break;
+        case VIEW_DETAIL:
+            ui_detail_handle_key(&st, ch);
+            break;
+        case VIEW_SETTINGS:
+            ui_settings_handle_key(&st, ch);
+            break;
+        case VIEW_HELP:
+            ui_help_handle_key(&st, ch);
+            break;
+        }
+    }
+
+    if (st.detail_loaded)
+        torrent_detail_free(&st.detail);
+    free(st.order);
+    torrent_list_free(&st.list);
+
+    endwin();
+    return 0;
+}
diff --git a/src/ui/ui.h b/src/ui/ui.h
new file mode 100644
index 0000000..129c348
--- /dev/null
+++ b/src/ui/ui.h
@@ -0,0 +1,168 @@
+#ifndef TRANSTUI_UI_H
+#define TRANSTUI_UI_H
+
+#include <curses.h>
+#include <time.h>
+#include "../config.h"
+#include "../rpc.h"
+#include "../torrent.h"
+
+typedef enum {
+    VIEW_LIST,
+    VIEW_DETAIL,
+    VIEW_SETTINGS,
+    VIEW_HELP,
+} ViewMode;
+
+/* Sidebar categories. Order here is the order shown in the left menu. */
+typedef enum {
+    FILTER_ALL,
+    FILTER_DOWNLOADING,
+    FILTER_UPLOADING,
+    FILTER_PAUSED,
+    FILTER_COMPLETED,
+    FILTER_COUNT,
+} FilterMode;
+
+typedef enum {
+    FOCUS_SIDEBAR,
+    FOCUS_LIST,
+} FocusZone;
+
+typedef enum {
+    SORT_NAME,
+    SORT_SIZE,
+    SORT_PROGRESS,
+    SORT_STATUS,
+    SORT_DOWN,
+    SORT_UP,
+    SORT_RATIO,
+    SORT_ETA,
+    SORT_COUNT,
+} SortColumn;
+
+typedef enum {
+    DETAIL_GENERAL,
+    DETAIL_FILES,
+    DETAIL_PEERS,
+    DETAIL_TRACKERS,
+    DETAIL_TAB_COUNT,
+} DetailTab;
+
+/* Color pairs, initialized once in ui_init_colors(). */
+enum {
+    CP_DEFAULT = 1,
+    CP_HEADER,
+    CP_SELROW,
+    CP_SEEDING,
+    CP_DOWNLOADING,
+    CP_PAUSED,
+    CP_ERROR,
+    CP_CHECK,
+    CP_BORDER,
+    CP_ACCENT,
+    CP_SPLASH_WHITE,
+};
+
+/* A single "key -> what it does" entry for the footer hint bar. */
+typedef struct {
+    const char *key;
+    const char *desc;
+} KeyHint;
+
+typedef struct {
+    RpcClient *rpc;
+    Config *cfg;
+    int running;
+
+    TorrentList list;
+    /* indices into list.items after filtering+sorting, rebuilt each frame */
+    int *order;
+    size_t order_count;
+    size_t order_capacity;
+
+    int cursor;    /* index into order[] */
+    int top;       /* first visible row, index into order[] */
+
+    SortColumn sort_col;
+    int sort_desc;
+    FilterMode filter;
+    char search[128];
+
+    FocusZone focus;
+    int sidebar_cursor;
+
+    ViewMode view;
+
+    int detail_id;
+    TorrentDetail detail;
+    int detail_loaded;
+    DetailTab detail_tab;
+    int detail_cursor;
+
+    char status_msg[256];
+    time_t status_msg_until;
+
+    struct timespec last_poll;
+    int need_poll_now;
+} AppState;
+
+int ui_run(RpcClient *rpc, Config *cfg);
+
+/* Shows the startup splash (block-letter logo) and returns once it's been on
+ * screen for a bit or the user presses a key, whichever is first. Returns
+ * that key (so the caller can act on it immediately instead of discarding
+ * it), or ERR if the splash simply timed out. */
+int ui_splash_show(void);
+
+/* Offers to fix a broken *local* connection: "no daemon answering" (offer to
+ * spawn transmission-daemon) or "daemon answered with 401" (offer to fix its
+ * RPC auth via sudo), based on rpc->last_http_status. No-op for non-local
+ * hosts (nothing we can sudo into on someone else's machine). Used both at
+ * startup and from the settings popup's 'r' retry key. */
+void ui_offer_reconnect_help(AppState *st);
+
+/* shared helpers, used across ui_*.c */
+void ui_set_status(AppState *st, const char *fmt, ...);
+void ui_init_colors(void);
+int ui_color_for_status(int status);
+void ui_rebuild_order(AppState *st);
+int ui_refresh_list(AppState *st);
+void ui_open_detail(AppState *st, int torrent_id);
+void ui_close_detail(AppState *st);
+
+/* Draws a rounded ╭─╮ box border into win (h x w, starting at 0,0 in win's
+ * own coordinates). Shared look for panels and popups. */
+void ui_box(WINDOW *win, int h, int w);
+/* Draws a title left-aligned into a box's top border, e.g. ui_box_title(win, w, "Filter"). */
+void ui_box_title(WINDOW *win, int w, const char *title);
+/* Renders a wrapping row of "key description" chips at the bottom `nrows`
+ * lines of the screen (nrows capped to 2). Hints that don't fit are dropped. */
+void ui_draw_footer(const KeyHint *hints, size_t n, int nrows);
+
+/* modal dialogs (ui_dialogs.c) - block until answered, then redraw caller's view */
+int ui_confirm(const char *title, const char *msg);
+int ui_prompt(const char *title, const char *initial, char *out, size_t outsize);
+void ui_message(const char *title, const char *msg);
+
+void ui_dialog_add_torrent(AppState *st);
+void ui_dialog_remove(AppState *st, const int *ids, size_t n);
+void ui_dialog_speed_limit(AppState *st, const int *ids, size_t n);
+
+/* per-view render+input handlers */
+void ui_list_render(AppState *st);
+/* Same as ui_list_render() but skips doupdate(), for use as a backdrop
+ * under a popup that wants a single atomic flush (see ui_settings.c). */
+void ui_list_render_content(AppState *st);
+void ui_list_handle_key(AppState *st, int ch);
+
+void ui_detail_render(AppState *st);
+void ui_detail_handle_key(AppState *st, int ch);
+
+void ui_settings_render(AppState *st);
+void ui_settings_handle_key(AppState *st, int ch);
+
+void ui_help_render(AppState *st);
+void ui_help_handle_key(AppState *st, int ch);
+
+#endif
diff --git a/src/ui/ui_details.c b/src/ui/ui_details.c
new file mode 100644
index 0000000..fb69642
--- /dev/null
+++ b/src/ui/ui_details.c
@@ -0,0 +1,290 @@
+#include "ui.h"
+#include "../util.h"
+
+#include <curses.h>
+#include <string.h>
+
+#define FOOTER_H 2
+
+static const char *TAB_LABEL[DETAIL_TAB_COUNT] = {"General", "Files", "Peers", "Trackers"};
+
+static void ensure_loaded(AppState *st)
+{
+    if (st->detail_loaded)
+        return;
+    char err[256];
+    if (torrent_get_detail(st->rpc, st->detail_id, &st->detail, err, sizeof(err)) == 0) {
+        st->detail_loaded = 1;
+    } else {
+        ui_set_status(st, "Error: %s", err);
+        st->view = VIEW_LIST;
+    }
+}
+
+static void draw_title(AppState *st, int cols)
+{
+    attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
+    mvhline(0, 0, ' ', cols);
+    mvprintw(0, 0, " %-*.*s", cols - 1, cols - 1,
+              st->detail_loaded ? st->detail.info.name : "");
+    attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
+}
+
+static void draw_general(WINDOW *win, AppState *st, int top)
+{
+    TorrentDetail *d = &st->detail;
+    char sizebuf[24], donebuf[24], downbuf[24], upbuf[24], addedbuf[32], createdbuf[32], donedatebuf[32];
+    fmt_size(d->info.total_size, sizebuf, sizeof(sizebuf));
+    fmt_size(d->info.total_size - d->info.left_until_done, donebuf, sizeof(donebuf));
+    fmt_speed(d->info.rate_download, downbuf, sizeof(downbuf));
+    fmt_speed(d->info.rate_upload, upbuf, sizeof(upbuf));
+    fmt_time(d->date_added, addedbuf, sizeof(addedbuf));
+    fmt_time(d->date_created, createdbuf, sizeof(createdbuf));
+    fmt_time(d->done_date, donedatebuf, sizeof(donedatebuf));
+
+    int y = top;
+    mvwprintw(win, y++, 2, "Hash:            %s", d->info.hash);
+    mvwprintw(win, y++, 2, "Size:            %s (%s done)", sizebuf, donebuf);
+    mvwprintw(win, y++, 2, "Progress:        %.1f%%", d->info.percent_done * 100.0);
+    mvwprintw(win, y++, 2, "Speed:           \xe2\x86\x93 %s   \xe2\x86\x91 %s", downbuf, upbuf);
+    mvwprintw(win, y++, 2, "Ratio:           %.2f", d->info.upload_ratio);
+    mvwprintw(win, y++, 2, "Peers:           %d connected (%d sending, %d receiving)",
+              d->info.peers_connected, d->info.peers_sending_to_us, d->info.peers_getting_from_us);
+    mvwprintw(win, y++, 2, "Down limit:      %s", d->download_limited ? "active" : "unlimited");
+    mvwprintw(win, y++, 2, "Up limit:        %s", d->upload_limited ? "active" : "unlimited");
+    mvwprintw(win, y++, 2, "Folder:          %s", d->info.download_dir);
+    mvwprintw(win, y++, 2, "Added:           %s", addedbuf);
+    mvwprintw(win, y++, 2, "Created:         %s", createdbuf);
+    mvwprintw(win, y++, 2, "Completed:       %s", donedatebuf);
+    if (d->info.error)
+        mvwprintw(win, y++, 2, "Error:           %s", d->info.error_string);
+}
+
+static void draw_files(WINDOW *win, AppState *st, int top, int rows, int cols)
+{
+    TorrentDetail *d = &st->detail;
+    if (d->file_count == 0) {
+        mvwprintw(win, top, 2, "(no file information)");
+        return;
+    }
+    if (st->detail_cursor >= (int)d->file_count)
+        st->detail_cursor = (int)d->file_count - 1;
+    if (st->detail_cursor < 0)
+        st->detail_cursor = 0;
+
+    int vis = rows;
+    int first = 0;
+    if (st->detail_cursor >= vis)
+        first = st->detail_cursor - vis + 1;
+
+    int name_w = cols - 36 > 10 ? cols - 36 : 10;
+    for (int row = 0; row < vis; row++) {
+        int i = first + row;
+        if (i >= (int)d->file_count)
+            break;
+        TorrentFile *f = &d->files[i];
+        char sizebuf[24];
+        fmt_size(f->length, sizebuf, sizeof(sizebuf));
+        double pct = f->length > 0 ? (100.0 * (double)f->bytes_completed / (double)f->length) : 100.0;
+        const char *prio = f->priority > 0 ? "High" : (f->priority < 0 ? "Low" : "Normal");
+
+        int attr = (i == st->detail_cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
+        mvwhline(win, top + row, 2, ' ', getmaxx(win) - 4);
+        wattron(win, attr);
+        mvwprintw(win, top + row, 2, "%c %-*.*s %9s %5.1f%% %-7s %s",
+                  f->wanted ? ' ' : '-', name_w, name_w, f->name, sizebuf, pct, prio,
+                  f->wanted ? "" : "(skipped)");
+        wattroff(win, attr);
+    }
+}
+
+static void draw_peers(WINDOW *win, AppState *st, int top, int rows)
+{
+    TorrentDetail *d = &st->detail;
+    if (d->peer_count == 0) {
+        mvwprintw(win, top, 2, "(no connected peers)");
+        return;
+    }
+    for (int row = 0; row < rows; row++) {
+        size_t i = (size_t)row;
+        if (i >= d->peer_count)
+            break;
+        TorrentPeer *p = &d->peers[i];
+        char downbuf[24], upbuf[24];
+        fmt_speed(p->rate_to_client, downbuf, sizeof(downbuf));
+        fmt_speed(p->rate_to_peer, upbuf, sizeof(upbuf));
+        mvwprintw(win, top + row, 2, "%-22s %-20s %5.1f%% %10s %10s %-8s",
+                  p->address, p->client_name, p->progress * 100.0, downbuf, upbuf, p->flags);
+    }
+}
+
+static void draw_trackers(WINDOW *win, AppState *st, int top, int rows, int cols)
+{
+    TorrentDetail *d = &st->detail;
+    if (d->tracker_count == 0) {
+        mvwprintw(win, top, 2, "(no trackers)");
+        return;
+    }
+    int url_w = cols - 22 > 10 ? cols - 22 : 10;
+    for (int row = 0; row < rows; row++) {
+        size_t i = (size_t)row;
+        if (i >= d->tracker_count)
+            break;
+        TorrentTracker *t = &d->trackers[i];
+        mvwprintw(win, top + row, 2, "[%d] %-*.*s S:%d L:%d", t->tier, url_w, url_w, t->announce,
+                  t->seeder_count, t->leecher_count);
+        if (t->last_announce_result[0])
+            mvwprintw(win, top + row + 1, 6, "%.*s", cols - 7, t->last_announce_result);
+    }
+}
+
+static const KeyHint DETAIL_HINTS[] = {
+    {"Esc/q", "Back"}, {"Tab", "Next tab"}, {"1-4", "Select tab"},
+    {"Space", "Select/skip (Files)"}, {"+/-", "Priority (Files)"},
+};
+
+void ui_detail_render(AppState *st)
+{
+    ensure_loaded(st);
+    if (st->view != VIEW_DETAIL)
+        return;
+
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    erase();
+    draw_title(st, cols);
+
+    int body_top = 1;
+    int body_h = rows - FOOTER_H - 1 - body_top;
+    if (body_h < 3)
+        body_h = 3;
+
+    WINDOW *win = derwin(stdscr, body_h, cols, body_top, 0);
+    ui_box(win, body_h, cols);
+
+    int x = 2;
+    for (int i = 0; i < DETAIL_TAB_COUNT; i++) {
+        int active = (i == (int)st->detail_tab);
+        wattron(win, active ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : (COLOR_PAIR(CP_ACCENT)));
+        mvwprintw(win, 0, x, " %d:%s ", i + 1, TAB_LABEL[i]);
+        wattroff(win, active ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : (COLOR_PAIR(CP_ACCENT)));
+        x += (int)strlen(TAB_LABEL[i]) + 5;
+    }
+
+    int content_top = 2;
+    int content_rows = body_h - content_top - 1;
+    if (content_rows < 1)
+        content_rows = 1;
+
+    switch (st->detail_tab) {
+    case DETAIL_GENERAL:
+        draw_general(win, st, content_top);
+        break;
+    case DETAIL_FILES:
+        draw_files(win, st, content_top, content_rows, cols);
+        break;
+    case DETAIL_PEERS:
+        draw_peers(win, st, content_top, content_rows);
+        break;
+    case DETAIL_TRACKERS:
+        draw_trackers(win, st, content_top, content_rows, cols);
+        break;
+    default:
+        break;
+    }
+
+    wnoutrefresh(win);
+    delwin(win);
+
+    ui_draw_footer(DETAIL_HINTS, sizeof(DETAIL_HINTS) / sizeof(DETAIL_HINTS[0]), FOOTER_H);
+    doupdate();
+}
+
+void ui_detail_handle_key(AppState *st, int ch)
+{
+    switch (ch) {
+    case 'q':
+    case 27:
+    case KEY_BACKSPACE:
+    case 127:
+        ui_close_detail(st);
+        return;
+    case '\t':
+        st->detail_tab = (st->detail_tab + 1) % DETAIL_TAB_COUNT;
+        st->detail_cursor = 0;
+        return;
+    case KEY_BTAB:
+        st->detail_tab = (st->detail_tab + DETAIL_TAB_COUNT - 1) % DETAIL_TAB_COUNT;
+        st->detail_cursor = 0;
+        return;
+    case '1':
+        st->detail_tab = DETAIL_GENERAL;
+        st->detail_cursor = 0;
+        return;
+    case '2':
+        st->detail_tab = DETAIL_FILES;
+        st->detail_cursor = 0;
+        return;
+    case '3':
+        st->detail_tab = DETAIL_PEERS;
+        st->detail_cursor = 0;
+        return;
+    case '4':
+        st->detail_tab = DETAIL_TRACKERS;
+        st->detail_cursor = 0;
+        return;
+    default:
+        break;
+    }
+
+    if (st->detail_tab == DETAIL_FILES && st->detail_loaded) {
+        TorrentDetail *d = &st->detail;
+        switch (ch) {
+        case KEY_UP:
+        case 'k':
+            if (st->detail_cursor > 0)
+                st->detail_cursor--;
+            break;
+        case KEY_DOWN:
+        case 'j':
+            if ((size_t)(st->detail_cursor + 1) < d->file_count)
+                st->detail_cursor++;
+            break;
+        case ' ': {
+            if ((size_t)st->detail_cursor >= d->file_count)
+                break;
+            TorrentFile *f = &d->files[st->detail_cursor];
+            int newwanted = !f->wanted;
+            char err[256];
+            if (torrent_set_file_wanted(st->rpc, st->detail_id, &st->detail_cursor, 1, newwanted,
+                                         err, sizeof(err)) == 0) {
+                f->wanted = newwanted;
+            } else {
+                ui_set_status(st, "Error: %s", err);
+            }
+            break;
+        }
+        case '+':
+        case '-': {
+            if ((size_t)st->detail_cursor >= d->file_count)
+                break;
+            TorrentFile *f = &d->files[st->detail_cursor];
+            int prio = f->priority + (ch == '+' ? 1 : -1);
+            if (prio > 1)
+                prio = 1;
+            if (prio < -1)
+                prio = -1;
+            char err[256];
+            if (torrent_set_file_priority(st->rpc, st->detail_id, &st->detail_cursor, 1, prio,
+                                           err, sizeof(err)) == 0) {
+                f->priority = prio;
+            } else {
+                ui_set_status(st, "Error: %s", err);
+            }
+            break;
+        }
+        default:
+            break;
+        }
+    }
+}
diff --git a/src/ui/ui_dialogs.c b/src/ui/ui_dialogs.c
new file mode 100644
index 0000000..5501603
--- /dev/null
+++ b/src/ui/ui_dialogs.c
@@ -0,0 +1,419 @@
+#include "ui.h"
+
+#include <ctype.h>
+#include <curses.h>
+#include <dirent.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+
+static WINDOW *open_box(int h, int w, const char *title)
+{
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    /* Callers size w against cols already, but not h against rows - clamp
+     * here too so a fixed-height dialog can't get pushed off a short
+     * terminal the same way the settings popup could (see ui_settings.c). */
+    if (h > rows)
+        h = rows;
+    if (w > cols)
+        w = cols;
+    int y = (rows - h) / 2;
+    int x = (cols - w) / 2;
+    if (y < 0)
+        y = 0;
+    if (x < 0)
+        x = 0;
+    WINDOW *win = newwin(h, w, y, x);
+    werase(win);
+    ui_box(win, h, w);
+    if (title)
+        ui_box_title(win, w, title);
+    return win;
+}
+
+static void close_box(WINDOW *win)
+{
+    delwin(win);
+    touchwin(stdscr);
+}
+
+int ui_confirm(const char *title, const char *msg)
+{
+    int w = (int)strlen(msg) + 8;
+    if (w < 30)
+        w = 30;
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    (void)rows;
+    if (w > cols - 4)
+        w = cols - 4;
+    WINDOW *win = open_box(5, w, title);
+    mvwprintw(win, 2, 2, "%.*s", w - 4, msg);
+    mvwprintw(win, 3, 2, "[y]es  [n]o");
+    wrefresh(win);
+
+    int result = 0;
+    for (;;) {
+        int ch = wgetch(win);
+        if (ch == 'y' || ch == 'Y') {
+            result = 1;
+            break;
+        }
+        if (ch == 'n' || ch == 'N' || ch == 27) {
+            result = 0;
+            break;
+        }
+    }
+    close_box(win);
+    return result;
+}
+
+void ui_message(const char *title, const char *msg)
+{
+    int w = (int)strlen(msg) + 8;
+    if (w < 30)
+        w = 30;
+    int cols = getmaxx(stdscr);
+    if (w > cols - 4)
+        w = cols - 4;
+    WINDOW *win = open_box(5, w, title);
+    mvwprintw(win, 2, 2, "%.*s", w - 4, msg);
+    mvwprintw(win, 3, 2, "Press any key...");
+    wrefresh(win);
+    wgetch(win);
+    close_box(win);
+}
+
+int ui_prompt(const char *title, const char *initial, char *out, size_t outsize)
+{
+    int cols = getmaxx(stdscr);
+    int w = cols - 8;
+    if (w > 70)
+        w = 70;
+    if (w < 30)
+        w = 30;
+    WINDOW *win = open_box(4, w, title);
+    keypad(win, TRUE);
+
+    char buf[512];
+    snprintf(buf, sizeof(buf), "%s", initial ? initial : "");
+    size_t len = strlen(buf);
+    size_t cap = sizeof(buf) - 1;
+    int field_w = w - 4;
+
+    curs_set(1);
+    int result = 0;
+    for (;;) {
+        mvwhline(win, 2, 2, ' ', field_w);
+        size_t show_from = 0;
+        if ((int)len >= field_w)
+            show_from = len - field_w + 1;
+        mvwprintw(win, 2, 2, "%s", buf + show_from);
+        wmove(win, 2, 2 + (int)(len - show_from));
+        wrefresh(win);
+
+        int ch = wgetch(win);
+        if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
+            result = 1;
+            break;
+        }
+        if (ch == 27) {
+            result = 0;
+            break;
+        }
+        if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) {
+            if (len > 0)
+                buf[--len] = '\0';
+            continue;
+        }
+        if (ch >= 32 && ch < 127 && len < cap) {
+            buf[len++] = (char)ch;
+            buf[len] = '\0';
+        }
+    }
+    curs_set(0);
+    close_box(win);
+
+    if (result)
+        snprintf(out, outsize, "%s", buf);
+    return result;
+}
+
+typedef struct {
+    char name[256];
+    int is_dir;
+} BrowseEntry;
+
+static int has_torrent_ext(const char *name)
+{
+    size_t n = strlen(name);
+    return n > 8 && strcasecmp(name + n - 8, ".torrent") == 0;
+}
+
+static int browse_entry_cmp(const void *a, const void *b)
+{
+    const BrowseEntry *ea = a, *eb = b;
+    if (ea->is_dir != eb->is_dir)
+        return eb->is_dir - ea->is_dir; /* directories first */
+    return strcasecmp(ea->name, eb->name);
+}
+
+/* Lists `path`'s entries into *out_entries (caller frees), showing only
+ * subdirectories and *.torrent files - a plain directory can otherwise be
+ * full of unrelated files that just make picking a torrent tedious. */
+static int load_dir(const char *path, BrowseEntry **out_entries, size_t *out_n)
+{
+    DIR *d = opendir(path);
+    if (!d)
+        return -1;
+
+    size_t cap = 64, n = 0;
+    BrowseEntry *entries = malloc(cap * sizeof(BrowseEntry));
+    if (strcmp(path, "/") != 0) {
+        snprintf(entries[n].name, sizeof(entries[n].name), "..");
+        entries[n].is_dir = 1;
+        n++;
+    }
+
+    struct dirent *de;
+    while ((de = readdir(d)) != NULL) {
+        if (de->d_name[0] == '.')
+            continue; /* skip "." and hidden files/dirs */
+        char full[1280];
+        snprintf(full, sizeof(full), "%s/%s", path, de->d_name);
+        struct stat st;
+        if (stat(full, &st) != 0)
+            continue;
+        int is_dir = S_ISDIR(st.st_mode);
+        if (!is_dir && !has_torrent_ext(de->d_name))
+            continue;
+        if (n == cap) {
+            cap *= 2;
+            BrowseEntry *ne = realloc(entries, cap * sizeof(BrowseEntry));
+            if (!ne)
+                break;
+            entries = ne;
+        }
+        snprintf(entries[n].name, sizeof(entries[n].name), "%s", de->d_name);
+        entries[n].is_dir = is_dir;
+        n++;
+    }
+    closedir(d);
+
+    /* Keep ".." pinned first, sort the rest. */
+    size_t sort_off = (n > 0 && strcmp(entries[0].name, "..") == 0) ? 1 : 0;
+    if (n > sort_off + 1)
+        qsort(entries + sort_off, n - sort_off, sizeof(BrowseEntry), browse_entry_cmp);
+
+    *out_entries = entries;
+    *out_n = n;
+    return 0;
+}
+
+/* Remembered across calls in this run so re-opening the browser picks up
+ * where you left off instead of always starting back at $HOME. */
+static char g_browse_dir[1024];
+
+/* Simple two-pane-free directory browser: navigate with arrows, Enter
+ * descends into a directory or selects a *.torrent file, Backspace goes up.
+ * Returns 1 with `out` filled on selection, 0 on cancel. */
+static int ui_file_browser(char *out, size_t outsize)
+{
+    if (!g_browse_dir[0]) {
+        const char *home = getenv("HOME");
+        snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", (home && home[0]) ? home : "/");
+    }
+    char path[1024];
+    snprintf(path, sizeof(path), "%s", g_browse_dir);
+
+    int cursor = 0, top = 0, result = 0;
+
+    for (;;) {
+        BrowseEntry *entries = NULL;
+        size_t n = 0;
+        if (load_dir(path, &entries, &n) != 0) {
+            ui_message("Error", "Could not open directory");
+            break;
+        }
+        if (cursor >= (int)n)
+            cursor = n ? (int)n - 1 : 0;
+        if (cursor < 0)
+            cursor = 0;
+
+        int rows, cols;
+        getmaxyx(stdscr, rows, cols);
+        int h = rows > 14 ? rows - 4 : rows;
+        int w = cols > 48 ? cols - 8 : cols;
+        WINDOW *win = open_box(h, w, "Select .torrent file");
+        keypad(win, TRUE);
+        int list_top = 2;
+        int list_h = h - list_top - 2;
+        if (list_h < 1)
+            list_h = 1;
+
+        int reload = 0; /* 0=keep reading input, 1=dir changed, 2=done */
+        while (!reload) {
+            werase(win);
+            ui_box(win, h, w);
+            ui_box_title(win, w, "Select .torrent file");
+            mvwprintw(win, 1, 2, "%.*s", w - 4, path);
+
+            if (cursor < top)
+                top = cursor;
+            if (cursor >= top + list_h)
+                top = cursor - list_h + 1;
+            if (top < 0)
+                top = 0;
+
+            if (n == 0)
+                mvwprintw(win, list_top, 2, "(no subfolders or .torrent files here)");
+            for (int row = 0; row < list_h; row++) {
+                int idx = top + row;
+                if ((size_t)idx >= n)
+                    break;
+                int attr = (idx == cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
+                wattron(win, attr);
+                mvwprintw(win, list_top + row, 2, "%s%-*.*s",
+                          entries[idx].is_dir ? "/ " : "  ", w - 6, w - 6, entries[idx].name);
+                wattroff(win, attr);
+            }
+            wattron(win, COLOR_PAIR(CP_BORDER));
+            mvwprintw(win, h - 2, 2, "%.*s", w - 4,
+                      "Enter=open/select  Backspace=up  Esc=cancel");
+            wattroff(win, COLOR_PAIR(CP_BORDER));
+            wrefresh(win);
+
+            int ch = wgetch(win);
+            switch (ch) {
+            case KEY_UP:
+            case 'k':
+                if (cursor > 0)
+                    cursor--;
+                break;
+            case KEY_DOWN:
+            case 'j':
+                if ((size_t)(cursor + 1) < n)
+                    cursor++;
+                break;
+            case KEY_BACKSPACE:
+            case 127:
+            case 8:
+            case KEY_LEFT:
+                if (strcmp(path, "/") != 0) {
+                    char *slash = strrchr(path, '/');
+                    if (slash == path)
+                        path[1] = '\0';
+                    else if (slash)
+                        *slash = '\0';
+                    cursor = 0;
+                    top = 0;
+                    reload = 1;
+                }
+                break;
+            case '\n':
+            case '\r':
+            case KEY_ENTER:
+                if (n > 0) {
+                    char candidate[1024];
+                    size_t plen = strlen(path);
+                    int root = (plen > 0 && path[plen - 1] == '/');
+                    if (strcmp(entries[cursor].name, "..") == 0) {
+                        snprintf(candidate, sizeof(candidate), "%s", path);
+                        char *slash = strrchr(candidate, '/');
+                        if (slash == candidate)
+                            candidate[1] = '\0';
+                        else if (slash)
+                            *slash = '\0';
+                    } else {
+                        snprintf(candidate, sizeof(candidate), root ? "%s%s" : "%s/%s", path,
+                                  entries[cursor].name);
+                    }
+                    if (entries[cursor].is_dir) {
+                        snprintf(path, sizeof(path), "%s", candidate);
+                        cursor = 0;
+                        top = 0;
+                        reload = 1;
+                    } else {
+                        snprintf(out, outsize, "%s", candidate);
+                        snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", path);
+                        result = 1;
+                        reload = 2;
+                    }
+                }
+                break;
+            case 27:
+            case 'q':
+                snprintf(g_browse_dir, sizeof(g_browse_dir), "%s", path);
+                result = 0;
+                reload = 2;
+                break;
+            default:
+                break;
+            }
+        }
+        free(entries);
+        close_box(win);
+        if (reload == 2)
+            break;
+    }
+    return result;
+}
+
+void ui_dialog_add_torrent(AppState *st)
+{
+    char source[1024] = "";
+    int have_source = 0;
+    if (ui_confirm("Add torrent", "Browse for a local .torrent file? (No = type magnet/URL/path)")) {
+        have_source = ui_file_browser(source, sizeof(source));
+    } else {
+        have_source = ui_prompt("Add torrent (magnet/URL/file path)", "", source, sizeof(source));
+    }
+    if (!have_source || source[0] == '\0')
+        return;
+
+    char dir[512] = "";
+    ui_prompt("Download folder (blank = default)", "", dir, sizeof(dir));
+
+    char err[256];
+    if (torrent_add(st->rpc, source, dir[0] ? dir : NULL, err, sizeof(err)) != 0)
+        ui_message("Error", err);
+    else
+        ui_set_status(st, "Torrent added");
+}
+
+void ui_dialog_remove(AppState *st, const int *ids, size_t n)
+{
+    char msg[128];
+    snprintf(msg, sizeof(msg), "Remove %zu torrent(s) from the list?", n);
+    if (!ui_confirm("Remove", msg))
+        return;
+
+    int delete_data = ui_confirm("Remove", "Also delete the files on disk?");
+
+    char err[256];
+    if (torrent_remove(st->rpc, ids, n, delete_data, err, sizeof(err)) != 0)
+        ui_set_status(st, "Error: %s", err);
+    else
+        ui_set_status(st, "Removed %zu torrent(s)%s", n, delete_data ? " (incl. data)" : "");
+}
+
+void ui_dialog_speed_limit(AppState *st, const int *ids, size_t n)
+{
+    char down[32] = "0", up[32] = "0";
+    if (!ui_prompt("Download limit KB/s (0 = unlimited)", "0", down, sizeof(down)))
+        return;
+    if (!ui_prompt("Upload limit KB/s (0 = unlimited)", "0", up, sizeof(up)))
+        return;
+
+    long down_kbps = strtol(down, NULL, 10);
+    long up_kbps = strtol(up, NULL, 10);
+
+    char err[256];
+    if (torrent_set_speed_limit(st->rpc, ids, n, down_kbps, down_kbps > 0, up_kbps, up_kbps > 0,
+                                 err, sizeof(err)) != 0)
+        ui_set_status(st, "Error: %s", err);
+    else
+        ui_set_status(st, "Speed limit set for %zu torrent(s)", n);
+}
diff --git a/src/ui/ui_help.c b/src/ui/ui_help.c
new file mode 100644
index 0000000..131ee3e
--- /dev/null
+++ b/src/ui/ui_help.c
@@ -0,0 +1,68 @@
+#include "ui.h"
+
+#include <curses.h>
+
+static const char *HELP_LINES[] = {
+    "Sidebar (focus with Tab / \xe2\x86\x90):",
+    "  \xe2\x86\x91/k \xe2\x86\x93/j       select filter category (All/Downloading/Uploading/Paused/Completed)",
+    "  Enter / \xe2\x86\x92 / Tab  move focus to the list",
+    "",
+    "List view (focus with Tab / \xe2\x86\x92):",
+    "  \xe2\x86\x91/k \xe2\x86\x93/j       move selection",
+    "  PgUp/PgDn      page up/down",
+    "  Tab / \xe2\x86\x90         move focus to the sidebar",
+    "  Enter          show details for the selected torrent",
+    "  space          select/deselect torrent (for batch actions)",
+    "  s / S / p      start / stop / toggle start-stop",
+    "  v              verify (recheck)",
+    "  d / Delete     remove (asks whether to delete data)",
+    "  l              set speed limit",
+    "  a              add torrent (magnet/URL/file)",
+    "  r              force refresh",
+    "  o / O          next sort column / reverse direction",
+    "  /              search by name",
+    "  Esc            clear search / deselect",
+    "  g              settings",
+    "  q              quit",
+    "",
+    "Detail view:",
+    "  Tab / 1-4      switch tab (General/Files/Peers/Trackers)",
+    "  space          (Files) select/skip file",
+    "  + / -          (Files) raise/lower priority",
+    "  Esc/q          back to the list",
+    "",
+    "Settings (opened with g):",
+    "  \xe2\x86\x91/k \xe2\x86\x93/j       navigate fields",
+    "  Enter/space    change value / toggle on/off",
+    "  Esc/q          close",
+    "",
+    "Press any key to go back.",
+};
+
+void ui_help_render(AppState *st)
+{
+    (void)st;
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    erase();
+
+    int h = rows - 2;
+    int w = cols - 2;
+    WINDOW *win = derwin(stdscr, h, w, 1, 1);
+    ui_box(win, h, w);
+    ui_box_title(win, w, "Help");
+
+    size_t n = sizeof(HELP_LINES) / sizeof(HELP_LINES[0]);
+    for (size_t i = 0; i < n && (int)i + 2 < h; i++)
+        mvwprintw(win, (int)i + 2, 2, "%.*s", w - 4, HELP_LINES[i]);
+
+    wnoutrefresh(win);
+    delwin(win);
+    doupdate();
+}
+
+void ui_help_handle_key(AppState *st, int ch)
+{
+    (void)ch;
+    st->view = VIEW_LIST;
+}
diff --git a/src/ui/ui_list.c b/src/ui/ui_list.c
new file mode 100644
index 0000000..b05aafd
--- /dev/null
+++ b/src/ui/ui_list.c
@@ -0,0 +1,515 @@
+#include "ui.h"
+#include "../util.h"
+
+#include <curses.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#define SIDEBAR_W 16
+#define FOOTER_H 2
+
+static const char *STATUS_LABEL[] = {
+    "Stopped", "Queued", "Checking", "Queued",
+    "Downloading", "Queued", "Seeding",
+};
+
+typedef struct {
+    const char *label;
+    FilterMode filter;
+} SidebarItem;
+
+static const SidebarItem SIDEBAR_ITEMS[] = {
+    {"All", FILTER_ALL},
+    {"Downloading", FILTER_DOWNLOADING},
+    {"Uploading", FILTER_UPLOADING},
+    {"Paused", FILTER_PAUSED},
+    {"Completed", FILTER_COMPLETED},
+};
+#define SIDEBAR_ITEM_COUNT (int)(sizeof(SIDEBAR_ITEMS) / sizeof(SIDEBAR_ITEMS[0]))
+
+static const char *SORT_LABEL[SORT_COUNT] = {
+    "Name", "Size", "Progress", "Status", "Down", "Up", "Ratio", "ETA",
+};
+
+static void draw_title(AppState *st, int cols)
+{
+    attron(COLOR_PAIR(CP_HEADER) | A_BOLD);
+    mvhline(0, 0, ' ', cols);
+    mvprintw(0, 0, " TransTUI  \xe2\x80\xa2  %s:%d  \xe2\x80\xa2  %s", st->cfg->host, st->cfg->port,
+              st->status_msg[0] && time(NULL) < st->status_msg_until ? "..." : "Connected");
+    if (st->search[0]) {
+        int x = cols - (int)strlen(st->search) - 12;
+        if (x > 20)
+            mvprintw(0, x, "Search: %s", st->search);
+    }
+    attroff(COLOR_PAIR(CP_HEADER) | A_BOLD);
+}
+
+static void draw_sidebar(AppState *st, int y, int x, int h, int w)
+{
+    WINDOW *win = derwin(stdscr, h, w, y, x);
+    if (st->focus == FOCUS_SIDEBAR)
+        wattron(win, A_BOLD);
+    ui_box(win, h, w);
+    if (st->focus == FOCUS_SIDEBAR)
+        wattroff(win, A_BOLD);
+    ui_box_title(win, w, "Filter");
+
+    for (int i = 0; i < SIDEBAR_ITEM_COUNT && i + 2 < h; i++) {
+        int active = (st->filter == SIDEBAR_ITEMS[i].filter);
+        int hover = (st->focus == FOCUS_SIDEBAR && st->sidebar_cursor == i);
+        int attr = 0;
+        if (hover)
+            attr = COLOR_PAIR(CP_SELROW) | A_BOLD;
+        else if (active)
+            attr = COLOR_PAIR(CP_ACCENT) | A_BOLD;
+        wattron(win, attr);
+        mvwprintw(win, i + 2, 1, "%s%-*s", active ? "\xe2\x9d\xaf " : "  ", w - 4, SIDEBAR_ITEMS[i].label);
+        wattroff(win, attr);
+    }
+    wnoutrefresh(win);
+    delwin(win);
+}
+
+/* Column x-offsets are computed once here and reused by both the header and
+ * the row renderer, so the two can never drift out of alignment. */
+typedef struct {
+    int mark, name, size, pct, status, down, up, eta, ratio;
+    int name_w;
+    int show_eta, show_ratio;
+} ContentCols;
+
+static ContentCols compute_cols(int content_w)
+{
+    ContentCols c;
+    memset(&c, 0, sizeof(c));
+    int fixed = 1 + 9 + 1 + 6 + 1 + 11 + 1 + 10 + 1 + 10;
+    c.show_eta = 1;
+    c.show_ratio = 1;
+    c.name_w = content_w - fixed - 2 * 7;
+    if (c.name_w < 10) {
+        c.show_ratio = 0;
+        c.name_w = content_w - fixed - 7;
+    }
+    if (c.name_w < 10) {
+        c.show_eta = 0;
+        c.name_w = content_w - fixed;
+    }
+    if (c.name_w < 6)
+        c.name_w = 6;
+
+    c.mark = 2;
+    c.name = c.mark + 1;
+    c.size = c.name + c.name_w + 1;
+    c.pct = c.size + 9 + 1;
+    c.status = c.pct + 6 + 1;
+    c.down = c.status + 11 + 1;
+    c.up = c.down + 10 + 1;
+    c.eta = c.up + 10 + 1;
+    c.ratio = c.eta + (c.show_eta ? 7 : 0);
+    return c;
+}
+
+static void draw_content_header(WINDOW *win, const ContentCols *c)
+{
+    wattron(win, A_BOLD);
+    mvwprintw(win, 1, c->name, "%-*.*s", c->name_w, c->name_w, "Name");
+    mvwprintw(win, 1, c->size, "%9s", "Size");
+    mvwprintw(win, 1, c->pct, "%6s", "%");
+    mvwprintw(win, 1, c->status, "%-11s", "Status");
+    mvwprintw(win, 1, c->down, "%10s", "Down");
+    mvwprintw(win, 1, c->up, "%10s", "Up");
+    if (c->show_eta)
+        mvwprintw(win, 1, c->eta, "%6s", "ETA");
+    if (c->show_ratio)
+        mvwprintw(win, 1, c->ratio, "%6s", "Ratio");
+    wattroff(win, A_BOLD);
+}
+
+static void draw_content_row(WINDOW *win, int y, const ContentCols *c, const Torrent *t, int is_cursor)
+{
+    char sizebuf[24], downbuf[24], upbuf[24], etabuf[16], ratiobuf[16];
+    fmt_size(t->total_size, sizebuf, sizeof(sizebuf));
+    fmt_speed(t->rate_download, downbuf, sizeof(downbuf));
+    fmt_speed(t->rate_upload, upbuf, sizeof(upbuf));
+    fmt_eta(t->eta, etabuf, sizeof(etabuf));
+    fmt_ratio(t->upload_ratio, ratiobuf, sizeof(ratiobuf));
+    const char *status = t->error ? "Error" : STATUS_LABEL[t->status];
+
+    int attr = is_cursor ? (COLOR_PAIR(CP_SELROW) | A_BOLD)
+                          : COLOR_PAIR(t->error ? CP_ERROR : ui_color_for_status(t->status));
+
+    /* Blank just the interior (not wclrtoeol - that would also wipe the
+     * box's right border character on this row). */
+    mvwhline(win, y, 1, ' ', getmaxx(win) - 2);
+    wattron(win, attr);
+    mvwprintw(win, y, c->mark, "%c", t->selected ? '*' : ' ');
+    mvwprintw(win, y, c->name, "%-*.*s", c->name_w, c->name_w, t->name);
+    mvwprintw(win, y, c->size, "%9s", sizebuf);
+    mvwprintw(win, y, c->pct, "%5.1f%%", t->percent_done * 100.0);
+    mvwprintw(win, y, c->status, "%-11s", status);
+    mvwprintw(win, y, c->down, "%10s", downbuf);
+    mvwprintw(win, y, c->up, "%10s", upbuf);
+    if (c->show_eta)
+        mvwprintw(win, y, c->eta, "%6s", etabuf);
+    if (c->show_ratio)
+        mvwprintw(win, y, c->ratio, "%6s", ratiobuf);
+    wattroff(win, attr);
+}
+
+static void draw_content(AppState *st, int y, int x, int h, int w)
+{
+    WINDOW *win = derwin(stdscr, h, w, y, x);
+    if (st->focus == FOCUS_LIST)
+        wattron(win, A_BOLD);
+    ui_box(win, h, w);
+    if (st->focus == FOCUS_LIST)
+        wattroff(win, A_BOLD);
+
+    char title[64];
+    const char *filter_label = "All";
+    for (int i = 0; i < SIDEBAR_ITEM_COUNT; i++)
+        if (SIDEBAR_ITEMS[i].filter == st->filter)
+            filter_label = SIDEBAR_ITEMS[i].label;
+    snprintf(title, sizeof(title), "%s (%zu)", filter_label, st->order_count);
+    ui_box_title(win, w, title);
+
+    ContentCols cols = compute_cols(w - 4);
+    draw_content_header(win, &cols);
+
+    int list_top = 2;
+    int list_h = h - list_top - 1;
+    if (list_h < 0)
+        list_h = 0;
+
+    if (st->cursor < st->top)
+        st->top = st->cursor;
+    if (st->cursor >= st->top + list_h)
+        st->top = st->cursor - list_h + 1;
+    if (st->top < 0)
+        st->top = 0;
+
+    for (int row = 0; row < list_h; row++) {
+        int idx = st->top + row;
+        if ((size_t)idx >= st->order_count)
+            break;
+        const Torrent *t = &st->list.items[st->order[idx]];
+        int is_cursor = (idx == st->cursor) && st->focus == FOCUS_LIST;
+        draw_content_row(win, list_top + row, &cols, t, is_cursor);
+    }
+
+    wnoutrefresh(win);
+    delwin(win);
+}
+
+static void draw_status_line(AppState *st, int y, int cols)
+{
+    move(y, 0);
+    clrtoeol();
+    if (st->status_msg[0] && time(NULL) < st->status_msg_until) {
+        attron(COLOR_PAIR(CP_ACCENT));
+        mvprintw(y, 1, "%.*s", cols - 2, st->status_msg);
+        attroff(COLOR_PAIR(CP_ACCENT));
+        return;
+    }
+    int64_t down = 0, up = 0;
+    for (size_t i = 0; i < st->list.count; i++) {
+        down += st->list.items[i].rate_download;
+        up += st->list.items[i].rate_upload;
+    }
+    char downbuf[24], upbuf[24];
+    fmt_speed(down, downbuf, sizeof(downbuf));
+    fmt_speed(up, upbuf, sizeof(upbuf));
+    mvprintw(y, 1, "%zu torrents \xe2\x80\xa2 \xe2\x86\x93 %s \xe2\x80\xa2 \xe2\x86\x91 %s \xe2\x80\xa2 sort: %s",
+              st->list.count, downbuf, upbuf, SORT_LABEL[st->sort_col]);
+}
+
+static const KeyHint LIST_HINTS_SIDEBAR[] = {
+    {"\xe2\x86\x91\xe2\x86\x93", "Filter"}, {"Tab", "Switch focus"}, {"Enter", "Apply"},
+    {"g", "Settings"}, {"?", "Help"}, {"q", "Quit"},
+};
+static const KeyHint LIST_HINTS_LIST[] = {
+    {"\xe2\x86\x91\xe2\x86\x93", "Navigate"}, {"Tab", "Filter"}, {"Enter", "Details"},
+    {"Space", "Select"}, {"s/S/p", "Start/Stop"}, {"v", "Verify"},
+    {"a", "Add"}, {"d", "Remove"}, {"l", "Speed"}, {"o/O", "Sort"},
+    {"/", "Search"}, {"g", "Settings"}, {"?", "Help"}, {"q", "Quit"},
+};
+
+/* Draws the list view into curses' pending-update buffer without flushing
+ * it (no doupdate()). Used both by ui_list_render() below and by the
+ * settings popup, which needs to draw this as a backdrop and then layer its
+ * own box on top before a single atomic doupdate() - otherwise the list-only
+ * backdrop would flash on screen for one frame before the popup appears. */
+void ui_list_render_content(AppState *st)
+{
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    erase();
+
+    draw_title(st, cols);
+
+    int body_top = 1;
+    int body_bottom = rows - FOOTER_H - 1 - 1;
+    int body_h = body_bottom - body_top + 1;
+    if (body_h < 3)
+        body_h = 3;
+
+    draw_sidebar(st, body_top, 0, body_h, SIDEBAR_W);
+    int content_x = SIDEBAR_W;
+    int content_w = cols - SIDEBAR_W;
+    if (content_w < 20)
+        content_w = 20;
+    draw_content(st, body_top, content_x, body_h, content_w);
+
+    draw_status_line(st, rows - FOOTER_H - 1, cols);
+
+    if (st->focus == FOCUS_SIDEBAR)
+        ui_draw_footer(LIST_HINTS_SIDEBAR, sizeof(LIST_HINTS_SIDEBAR) / sizeof(LIST_HINTS_SIDEBAR[0]), FOOTER_H);
+    else
+        ui_draw_footer(LIST_HINTS_LIST, sizeof(LIST_HINTS_LIST) / sizeof(LIST_HINTS_LIST[0]), FOOTER_H);
+}
+
+void ui_list_render(AppState *st)
+{
+    ui_list_render_content(st);
+    doupdate();
+}
+
+static int *collect_target_ids(AppState *st, size_t *out_n)
+{
+    size_t cnt = 0;
+    for (size_t i = 0; i < st->list.count; i++)
+        if (st->list.items[i].selected)
+            cnt++;
+
+    if (cnt == 0) {
+        if (st->order_count == 0) {
+            *out_n = 0;
+            return NULL;
+        }
+        int *buf = malloc(sizeof(int));
+        buf[0] = st->list.items[st->order[st->cursor]].id;
+        *out_n = 1;
+        return buf;
+    }
+
+    int *buf = malloc(cnt * sizeof(int));
+    size_t j = 0;
+    for (size_t i = 0; i < st->list.count; i++)
+        if (st->list.items[i].selected)
+            buf[j++] = st->list.items[i].id;
+    *out_n = cnt;
+    return buf;
+}
+
+static void do_action(AppState *st, const char *method, const char *verb)
+{
+    size_t n;
+    int *ids = collect_target_ids(st, &n);
+    if (!ids || n == 0) {
+        free(ids);
+        return;
+    }
+    char err[256];
+    if (torrent_action(st->rpc, method, ids, n, err, sizeof(err)) != 0)
+        ui_set_status(st, "Error: %s", err);
+    else
+        ui_set_status(st, "%s %zu torrent(s)", verb, n);
+    free(ids);
+    st->need_poll_now = 1;
+}
+
+static void do_search(AppState *st)
+{
+    char buf[128];
+    snprintf(buf, sizeof(buf), "%s", st->search);
+    if (ui_prompt("Search", buf, buf, sizeof(buf))) {
+        snprintf(st->search, sizeof(st->search), "%s", buf);
+        st->cursor = 0;
+    }
+}
+
+static void handle_sidebar_key(AppState *st, int ch)
+{
+    switch (ch) {
+    case KEY_UP:
+    case 'k':
+        if (st->sidebar_cursor > 0) {
+            st->sidebar_cursor--;
+            st->filter = SIDEBAR_ITEMS[st->sidebar_cursor].filter;
+            st->cursor = 0;
+        }
+        break;
+    case KEY_DOWN:
+    case 'j':
+        if (st->sidebar_cursor + 1 < SIDEBAR_ITEM_COUNT) {
+            st->sidebar_cursor++;
+            st->filter = SIDEBAR_ITEMS[st->sidebar_cursor].filter;
+            st->cursor = 0;
+        }
+        break;
+    case '\t':
+    case '\n':
+    case '\r':
+    case KEY_ENTER:
+    case KEY_RIGHT:
+        st->focus = FOCUS_LIST;
+        break;
+    case 'q':
+    case 'Q':
+        st->running = 0;
+        break;
+    case 'g':
+        st->view = VIEW_SETTINGS;
+        break;
+    case '?':
+        st->view = VIEW_HELP;
+        break;
+    default:
+        break;
+    }
+}
+
+static void handle_list_key(AppState *st, int ch)
+{
+    int page = 10;
+    {
+        int rows, cols;
+        getmaxyx(stdscr, rows, cols);
+        (void)cols;
+        page = rows - 8 > 1 ? rows - 8 : 1;
+    }
+
+    switch (ch) {
+    case 'q':
+    case 'Q':
+        st->running = 0;
+        break;
+    case '\t':
+    case KEY_LEFT:
+        st->focus = FOCUS_SIDEBAR;
+        st->sidebar_cursor = st->filter;
+        break;
+    case KEY_UP:
+    case 'k':
+        if (st->cursor > 0)
+            st->cursor--;
+        break;
+    case KEY_DOWN:
+    case 'j':
+        if ((size_t)(st->cursor + 1) < st->order_count)
+            st->cursor++;
+        break;
+    case KEY_PPAGE:
+        st->cursor -= page;
+        if (st->cursor < 0)
+            st->cursor = 0;
+        break;
+    case KEY_NPAGE:
+        st->cursor += page;
+        if ((size_t)st->cursor >= st->order_count)
+            st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
+        break;
+    case KEY_HOME:
+        st->cursor = 0;
+        break;
+    case KEY_END:
+        st->cursor = st->order_count ? (int)st->order_count - 1 : 0;
+        break;
+    case ' ':
+        if ((size_t)st->cursor < st->order_count) {
+            Torrent *t = &st->list.items[st->order[st->cursor]];
+            t->selected = !t->selected;
+            if ((size_t)(st->cursor + 1) < st->order_count)
+                st->cursor++;
+        }
+        break;
+    case '\n':
+    case '\r':
+    case KEY_ENTER:
+        if ((size_t)st->cursor < st->order_count)
+            ui_open_detail(st, st->list.items[st->order[st->cursor]].id);
+        break;
+    case 's':
+        do_action(st, "torrent-start", "Started");
+        break;
+    case 'S':
+        do_action(st, "torrent-stop", "Stopped");
+        break;
+    case 'p': {
+        if ((size_t)st->cursor < st->order_count) {
+            const Torrent *t = &st->list.items[st->order[st->cursor]];
+            do_action(st, t->status == TR_STATUS_STOPPED ? "torrent-start" : "torrent-stop",
+                      t->status == TR_STATUS_STOPPED ? "Started" : "Stopped");
+        }
+        break;
+    }
+    case 'v':
+        do_action(st, "torrent-verify", "Verifying");
+        break;
+    case 'd':
+    case KEY_DC: {
+        size_t n;
+        int *ids = collect_target_ids(st, &n);
+        if (ids && n) {
+            ui_dialog_remove(st, ids, n);
+            st->need_poll_now = 1;
+        }
+        free(ids);
+        break;
+    }
+    case 'l': {
+        size_t n;
+        int *ids = collect_target_ids(st, &n);
+        if (ids && n)
+            ui_dialog_speed_limit(st, ids, n);
+        free(ids);
+        break;
+    }
+    case 'a':
+        ui_dialog_add_torrent(st);
+        st->need_poll_now = 1;
+        break;
+    case 'r':
+        st->need_poll_now = 1;
+        break;
+    case 'o':
+        st->sort_col = (st->sort_col + 1) % SORT_COUNT;
+        ui_set_status(st, "Sort: %s", SORT_LABEL[st->sort_col]);
+        break;
+    case 'O':
+        st->sort_desc = !st->sort_desc;
+        ui_set_status(st, "Sort: %s (%s)", SORT_LABEL[st->sort_col],
+                      st->sort_desc ? "descending" : "ascending");
+        break;
+    case '/':
+        do_search(st);
+        break;
+    case 27: /* Esc */
+        if (st->search[0]) {
+            st->search[0] = '\0';
+            st->cursor = 0;
+        } else {
+            for (size_t i = 0; i < st->list.count; i++)
+                st->list.items[i].selected = 0;
+        }
+        break;
+    case 'g':
+        st->view = VIEW_SETTINGS;
+        break;
+    case '?':
+        st->view = VIEW_HELP;
+        break;
+    default:
+        break;
+    }
+}
+
+void ui_list_handle_key(AppState *st, int ch)
+{
+    if (st->focus == FOCUS_SIDEBAR)
+        handle_sidebar_key(st, ch);
+    else
+        handle_list_key(st, ch);
+}
diff --git a/src/ui/ui_settings.c b/src/ui/ui_settings.c
new file mode 100644
index 0000000..b92db60
--- /dev/null
+++ b/src/ui/ui_settings.c
@@ -0,0 +1,486 @@
+#include "ui.h"
+#include "../third_party/cJSON.h"
+
+#include <curses.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct {
+    int loaded;
+    char download_dir[512];
+    long speed_down;
+    int speed_down_enabled;
+    long speed_up;
+    int speed_up_enabled;
+    int alt_enabled;
+    long alt_down;
+    long alt_up;
+    int alt_time_enabled;
+    int alt_time_begin;
+    int alt_time_end;
+    double seed_ratio_limit;
+    int seed_ratio_enabled;
+} SessionInfo;
+
+static SessionInfo g_sess;
+static int g_cursor;
+static int g_attempted;  /* we tried session-get at least once since opening */
+static int g_connected;  /* whether that attempt (or the latest one) succeeded */
+
+enum {
+    /* Daemon connection - these live in Config, not the RPC session, and
+     * are saved to config.ini (CLI flags still override them at startup). */
+    F_HOST,
+    F_PORT,
+    F_USERNAME,
+    F_PASSWORD,
+    F_SHOW_SPLASH,
+    /* Everything below is read/written via Transmission's session-get/set. */
+    F_DOWNLOAD_DIR,
+    F_SPEED_DOWN_ENABLED,
+    F_SPEED_DOWN,
+    F_SPEED_UP_ENABLED,
+    F_SPEED_UP,
+    F_ALT_ENABLED,
+    F_ALT_DOWN,
+    F_ALT_UP,
+    F_ALT_TIME_ENABLED,
+    F_ALT_TIME_BEGIN,
+    F_ALT_TIME_END,
+    F_SEED_RATIO_ENABLED,
+    F_SEED_RATIO,
+    F_COUNT,
+};
+#define F_DAEMON_COUNT (F_DOWNLOAD_DIR)
+
+static long ji(const cJSON *o, const char *k, long def)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+    return (v && cJSON_IsNumber(v)) ? (long)v->valuedouble : def;
+}
+static double jd(const cJSON *o, const char *k, double def)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+    return (v && cJSON_IsNumber(v)) ? v->valuedouble : def;
+}
+static int jb(const cJSON *o, const char *k, int def)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+    return v ? cJSON_IsTrue(v) : def;
+}
+static void js(const cJSON *o, const char *k, char *buf, size_t n)
+{
+    const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
+    if (v && cJSON_IsString(v))
+        snprintf(buf, n, "%s", v->valuestring);
+}
+
+static int fetch_session(AppState *st, char *err, size_t errlen)
+{
+    cJSON *args = NULL;
+    if (rpc_call(st->rpc, "session-get", NULL, &args, err, errlen) != 0)
+        return -1;
+
+    memset(&g_sess, 0, sizeof(g_sess));
+    js(args, "download-dir", g_sess.download_dir, sizeof(g_sess.download_dir));
+    g_sess.speed_down = ji(args, "speed-limit-down", 0);
+    g_sess.speed_down_enabled = jb(args, "speed-limit-down-enabled", 0);
+    g_sess.speed_up = ji(args, "speed-limit-up", 0);
+    g_sess.speed_up_enabled = jb(args, "speed-limit-up-enabled", 0);
+    g_sess.alt_enabled = jb(args, "alt-speed-enabled", 0);
+    g_sess.alt_down = ji(args, "alt-speed-down", 0);
+    g_sess.alt_up = ji(args, "alt-speed-up", 0);
+    g_sess.alt_time_enabled = jb(args, "alt-speed-time-enabled", 0);
+    g_sess.alt_time_begin = (int)ji(args, "alt-speed-time-begin", 0);
+    g_sess.alt_time_end = (int)ji(args, "alt-speed-time-end", 0);
+    g_sess.seed_ratio_limit = jd(args, "seedRatioLimit", 0.0);
+    g_sess.seed_ratio_enabled = jb(args, "seedRatioLimited", 0);
+    g_sess.loaded = 1;
+
+    cJSON_Delete(args);
+    return 0;
+}
+
+static int push_session(AppState *st, cJSON *args, char *err, size_t errlen)
+{
+    return rpc_call(st->rpc, "session-set", args, NULL, err, errlen);
+}
+
+static void fmt_hhmm(int minutes, char *buf, size_t n)
+{
+    snprintf(buf, n, "%02d:%02d", (minutes / 60) % 24, minutes % 60);
+}
+
+static int parse_hhmm(const char *s)
+{
+    int h = 0, m = 0;
+    if (sscanf(s, "%d:%d", &h, &m) != 2)
+        return -1;
+    if (h < 0 || h > 23 || m < 0 || m > 59)
+        return -1;
+    return h * 60 + m;
+}
+
+/* Popup geometry, shared between render and the row->y mapping used by input. */
+static int g_popup_h, g_popup_w;
+
+static int field_row(int field)
+{
+    int row = field;
+    if (field >= F_DOWNLOAD_DIR)
+        row += 1; /* blank separator line between daemon and session fields */
+    return row;
+}
+
+void ui_settings_render(AppState *st)
+{
+    /* Always open the popup, even if the daemon is unreachable - that's
+     * often exactly when the user needs it, to fix the host/port. Only the
+     * session-derived fields (download dir, speed limits, ...) depend on a
+     * live connection; the daemon fields below always work. */
+    if (!g_attempted) {
+        char err[256];
+        g_connected = (fetch_session(st, err, sizeof(err)) == 0);
+        if (!g_connected) {
+            memset(&g_sess, 0, sizeof(g_sess));
+            ui_set_status(st, "Not connected: %s", err);
+        }
+        g_attempted = 1;
+        g_cursor = 0;
+    }
+
+    /* Full-screen view rather than a popup layered over the list backdrop -
+     * matches ui_help_render()'s pattern. An overlapping popup+backdrop here
+     * turned out unreliable in practice (state changed correctly on 'g' but
+     * nothing visibly redrew until a later, unrelated screen update forced
+     * it), and a dedicated screen sidesteps that class of issue entirely. */
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+    erase();
+
+    g_popup_h = rows - 2;
+    g_popup_w = cols - 2;
+    if (g_popup_h < 4)
+        g_popup_h = rows > 4 ? 4 : rows;
+    if (g_popup_w < 10)
+        g_popup_w = cols > 10 ? 10 : cols;
+
+    WINDOW *win = derwin(stdscr, g_popup_h, g_popup_w, 1, 1);
+    ui_box(win, g_popup_h, g_popup_w);
+    ui_box_title(win, g_popup_w, g_connected ? "Settings" : "Settings - not connected");
+
+    char hhmm_begin[8], hhmm_end[8];
+    fmt_hhmm(g_sess.alt_time_begin, hhmm_begin, sizeof(hhmm_begin));
+    fmt_hhmm(g_sess.alt_time_end, hhmm_end, sizeof(hhmm_end));
+
+    char line[F_COUNT][640];
+    snprintf(line[F_HOST], sizeof(line[0]), "Host:                        %s", st->cfg->host);
+    snprintf(line[F_PORT], sizeof(line[0]), "Port:                        %d", st->cfg->port);
+    snprintf(line[F_USERNAME], sizeof(line[0]), "Username:                    %s",
+             st->cfg->username[0] ? st->cfg->username : "(none)");
+    snprintf(line[F_PASSWORD], sizeof(line[0]), "Password:                    %s",
+             st->cfg->password[0] ? "********" : "(none)");
+    snprintf(line[F_SHOW_SPLASH], sizeof(line[0]), "Splash screen:               %s",
+             st->cfg->show_splash ? "On" : "Off");
+    snprintf(line[F_DOWNLOAD_DIR], sizeof(line[0]), "Download folder:             %s",
+             g_connected ? g_sess.download_dir : "(not connected)");
+    snprintf(line[F_SPEED_DOWN_ENABLED], sizeof(line[0]), "Download limit:              %s",
+             g_sess.speed_down_enabled ? "On" : "Off");
+    snprintf(line[F_SPEED_DOWN], sizeof(line[0]), "  Limit (KB/s):              %ld", g_sess.speed_down);
+    snprintf(line[F_SPEED_UP_ENABLED], sizeof(line[0]), "Upload limit:                %s",
+             g_sess.speed_up_enabled ? "On" : "Off");
+    snprintf(line[F_SPEED_UP], sizeof(line[0]), "  Limit (KB/s):              %ld", g_sess.speed_up);
+    snprintf(line[F_ALT_ENABLED], sizeof(line[0]), "Alternative speed:           %s",
+             g_sess.alt_enabled ? "On" : "Off");
+    snprintf(line[F_ALT_DOWN], sizeof(line[0]), "  Down (KB/s):               %ld", g_sess.alt_down);
+    snprintf(line[F_ALT_UP], sizeof(line[0]), "  Up (KB/s):                 %ld", g_sess.alt_up);
+    snprintf(line[F_ALT_TIME_ENABLED], sizeof(line[0]), "  Schedule:                  %s",
+             g_sess.alt_time_enabled ? "On" : "Off");
+    snprintf(line[F_ALT_TIME_BEGIN], sizeof(line[0]), "    Start:                   %s", hhmm_begin);
+    snprintf(line[F_ALT_TIME_END], sizeof(line[0]), "    End:                     %s", hhmm_end);
+    snprintf(line[F_SEED_RATIO_ENABLED], sizeof(line[0]), "Ratio limit:                 %s",
+             g_sess.seed_ratio_enabled ? "On" : "Off");
+    snprintf(line[F_SEED_RATIO], sizeof(line[0]), "  Ratio:                     %.2f", g_sess.seed_ratio_limit);
+
+    int top = 2;
+    int field_w = g_popup_w - 4;
+    for (int i = 0; i < F_COUNT; i++) {
+        int y = top + field_row(i);
+        if (y >= g_popup_h - 2)
+            break;
+        int attr = (i == g_cursor) ? (COLOR_PAIR(CP_SELROW) | A_BOLD) : 0;
+        wattron(win, attr);
+        mvwprintw(win, y, 2, "%-*.*s", field_w, field_w, line[i]);
+        wattroff(win, attr);
+    }
+
+    wattron(win, COLOR_PAIR(CP_BORDER));
+    mvwprintw(win, g_popup_h - 2, 2, "%.*s", field_w,
+              g_connected
+                  ? "Esc/q=close  Enter/space=change  \xe2\x86\x91\xe2\x86\x93=navigate"
+                  : "Esc/q=close  Enter/space=change  r=reconnect  \xe2\x86\x91\xe2\x86\x93=navigate");
+    wattroff(win, COLOR_PAIR(CP_BORDER));
+
+    wnoutrefresh(win);
+    delwin(win);
+    doupdate();
+}
+
+static void apply_field(AppState *st, int field)
+{
+    cJSON *args = cJSON_CreateObject();
+    char err[256];
+    int ok = 1;
+    int daemon_changed = 0;
+    int local_changed = 0; /* config.ini-only setting, no reconnect needed */
+
+    switch (field) {
+    case F_HOST: {
+        char v[256];
+        snprintf(v, sizeof(v), "%s", st->cfg->host);
+        if (ui_prompt("Host", v, v, sizeof(v)) && v[0]) {
+            snprintf(st->cfg->host, sizeof(st->cfg->host), "%s", v);
+            daemon_changed = 1;
+        }
+        ok = 0; /* not a session-set field */
+        break;
+    }
+    case F_PORT: {
+        char v[16];
+        snprintf(v, sizeof(v), "%d", st->cfg->port);
+        if (ui_prompt("Port", v, v, sizeof(v))) {
+            int p = atoi(v);
+            if (p > 0 && p <= 65535) {
+                st->cfg->port = p;
+                daemon_changed = 1;
+            } else {
+                ui_set_status(st, "Invalid port");
+            }
+        }
+        ok = 0;
+        break;
+    }
+    case F_USERNAME: {
+        char v[128];
+        snprintf(v, sizeof(v), "%s", st->cfg->username);
+        if (ui_prompt("Username", v, v, sizeof(v))) {
+            snprintf(st->cfg->username, sizeof(st->cfg->username), "%s", v);
+            daemon_changed = 1;
+        }
+        ok = 0;
+        break;
+    }
+    case F_PASSWORD: {
+        char v[128];
+        snprintf(v, sizeof(v), "%s", st->cfg->password);
+        if (ui_prompt("Password", v, v, sizeof(v))) {
+            snprintf(st->cfg->password, sizeof(st->cfg->password), "%s", v);
+            daemon_changed = 1;
+        }
+        ok = 0;
+        break;
+    }
+    case F_SHOW_SPLASH:
+        st->cfg->show_splash = !st->cfg->show_splash;
+        local_changed = 1;
+        ok = 0;
+        break;
+    case F_DOWNLOAD_DIR: {
+        char v[512];
+        snprintf(v, sizeof(v), "%s", g_sess.download_dir);
+        if (ui_prompt("Download folder", v, v, sizeof(v))) {
+            snprintf(g_sess.download_dir, sizeof(g_sess.download_dir), "%s", v);
+            cJSON_AddStringToObject(args, "download-dir", g_sess.download_dir);
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_SPEED_DOWN_ENABLED:
+        g_sess.speed_down_enabled = !g_sess.speed_down_enabled;
+        cJSON_AddBoolToObject(args, "speed-limit-down-enabled", g_sess.speed_down_enabled);
+        break;
+    case F_SPEED_DOWN: {
+        char v[32];
+        snprintf(v, sizeof(v), "%ld", g_sess.speed_down);
+        if (ui_prompt("Download limit KB/s", v, v, sizeof(v))) {
+            g_sess.speed_down = strtol(v, NULL, 10);
+            cJSON_AddNumberToObject(args, "speed-limit-down", (double)g_sess.speed_down);
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_SPEED_UP_ENABLED:
+        g_sess.speed_up_enabled = !g_sess.speed_up_enabled;
+        cJSON_AddBoolToObject(args, "speed-limit-up-enabled", g_sess.speed_up_enabled);
+        break;
+    case F_SPEED_UP: {
+        char v[32];
+        snprintf(v, sizeof(v), "%ld", g_sess.speed_up);
+        if (ui_prompt("Upload limit KB/s", v, v, sizeof(v))) {
+            g_sess.speed_up = strtol(v, NULL, 10);
+            cJSON_AddNumberToObject(args, "speed-limit-up", (double)g_sess.speed_up);
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_ALT_ENABLED:
+        g_sess.alt_enabled = !g_sess.alt_enabled;
+        cJSON_AddBoolToObject(args, "alt-speed-enabled", g_sess.alt_enabled);
+        break;
+    case F_ALT_DOWN: {
+        char v[32];
+        snprintf(v, sizeof(v), "%ld", g_sess.alt_down);
+        if (ui_prompt("Alternative download speed KB/s", v, v, sizeof(v))) {
+            g_sess.alt_down = strtol(v, NULL, 10);
+            cJSON_AddNumberToObject(args, "alt-speed-down", (double)g_sess.alt_down);
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_ALT_UP: {
+        char v[32];
+        snprintf(v, sizeof(v), "%ld", g_sess.alt_up);
+        if (ui_prompt("Alternative upload speed KB/s", v, v, sizeof(v))) {
+            g_sess.alt_up = strtol(v, NULL, 10);
+            cJSON_AddNumberToObject(args, "alt-speed-up", (double)g_sess.alt_up);
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_ALT_TIME_ENABLED:
+        g_sess.alt_time_enabled = !g_sess.alt_time_enabled;
+        cJSON_AddBoolToObject(args, "alt-speed-time-enabled", g_sess.alt_time_enabled);
+        break;
+    case F_ALT_TIME_BEGIN: {
+        char v[8];
+        fmt_hhmm(g_sess.alt_time_begin, v, sizeof(v));
+        if (ui_prompt("Start time (HH:MM)", v, v, sizeof(v))) {
+            int mins = parse_hhmm(v);
+            if (mins < 0) {
+                ui_set_status(st, "Invalid time, use HH:MM");
+                ok = 0;
+            } else {
+                g_sess.alt_time_begin = mins;
+                cJSON_AddNumberToObject(args, "alt-speed-time-begin", mins);
+            }
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_ALT_TIME_END: {
+        char v[8];
+        fmt_hhmm(g_sess.alt_time_end, v, sizeof(v));
+        if (ui_prompt("End time (HH:MM)", v, v, sizeof(v))) {
+            int mins = parse_hhmm(v);
+            if (mins < 0) {
+                ui_set_status(st, "Invalid time, use HH:MM");
+                ok = 0;
+            } else {
+                g_sess.alt_time_end = mins;
+                cJSON_AddNumberToObject(args, "alt-speed-time-end", mins);
+            }
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    case F_SEED_RATIO_ENABLED:
+        g_sess.seed_ratio_enabled = !g_sess.seed_ratio_enabled;
+        cJSON_AddBoolToObject(args, "seedRatioLimited", g_sess.seed_ratio_enabled);
+        break;
+    case F_SEED_RATIO: {
+        char v[32];
+        snprintf(v, sizeof(v), "%.2f", g_sess.seed_ratio_limit);
+        if (ui_prompt("Ratio limit", v, v, sizeof(v))) {
+            g_sess.seed_ratio_limit = strtod(v, NULL);
+            cJSON_AddNumberToObject(args, "seedRatioLimit", g_sess.seed_ratio_limit);
+        } else {
+            ok = 0;
+        }
+        break;
+    }
+    default:
+        ok = 0;
+        break;
+    }
+
+    if (daemon_changed) {
+        /* Live-apply to the RPC client and start a fresh CSRF handshake -
+         * the daemon we're now talking to may not know our old session id. */
+        snprintf(st->rpc->host, sizeof(st->rpc->host), "%s", st->cfg->host);
+        st->rpc->port = st->cfg->port;
+        snprintf(st->rpc->user, sizeof(st->rpc->user), "%s", st->cfg->username);
+        snprintf(st->rpc->pass, sizeof(st->rpc->pass), "%s", st->cfg->password);
+        st->rpc->session_id[0] = '\0';
+
+        if (config_save(st->cfg, err, sizeof(err)) != 0)
+            ui_set_status(st, "Error saving: %s", err);
+        else
+            ui_set_status(st, "Connection settings saved");
+        g_attempted = 0; /* re-fetch session-get against the (possibly new) daemon */
+        st->need_poll_now = 1;
+        cJSON_Delete(args);
+        return;
+    }
+
+    if (local_changed) {
+        if (config_save(st->cfg, err, sizeof(err)) != 0)
+            ui_set_status(st, "Error saving: %s", err);
+        else
+            ui_set_status(st, "Setting saved");
+        cJSON_Delete(args);
+        return;
+    }
+
+    if (!ok || cJSON_GetArraySize(args) == 0) {
+        cJSON_Delete(args);
+        return;
+    }
+
+    if (push_session(st, args, err, sizeof(err)) != 0)
+        ui_set_status(st, "Error: %s", err);
+    else
+        ui_set_status(st, "Setting saved");
+}
+
+void ui_settings_handle_key(AppState *st, int ch)
+{
+    switch (ch) {
+    case 'q':
+    case 27:
+        g_attempted = 0;
+        st->view = VIEW_LIST;
+        break;
+    case 'r':
+        if (!g_connected) {
+            ui_offer_reconnect_help(st);
+            g_attempted = 0; /* retry session-get on next render either way */
+        }
+        break;
+    case KEY_UP:
+    case 'k':
+        if (g_cursor > 0)
+            g_cursor--;
+        break;
+    case KEY_DOWN:
+    case 'j':
+        if (g_cursor + 1 < F_COUNT)
+            g_cursor++;
+        break;
+    case '\n':
+    case '\r':
+    case KEY_ENTER:
+    case ' ':
+        apply_field(st, g_cursor);
+        break;
+    default:
+        break;
+    }
+}
diff --git a/src/ui/ui_splash.c b/src/ui/ui_splash.c
new file mode 100644
index 0000000..b5d9df0
--- /dev/null
+++ b/src/ui/ui_splash.c
@@ -0,0 +1,113 @@
+#include "ui.h"
+
+#include <curses.h>
+#include <string.h>
+#include <time.h>
+
+#define GLYPH_H 5
+#define GLYPH_W 5
+#define LETTER_GAP 1
+
+typedef const char *Glyph[GLYPH_H];
+
+/* Blocky 5x5 "16-bit" pixel font, just enough letters to spell TRANSTUI. */
+static const Glyph GLYPH_T = {"\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", "  \xe2\x96\x88  ", "  \xe2\x96\x88  ",
+                               "  \xe2\x96\x88  ", "  \xe2\x96\x88  "};
+static const Glyph GLYPH_R = {"\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ", "\xe2\x96\x88   \xe2\x96\x88",
+                               "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ", "\xe2\x96\x88  \xe2\x96\x88 ", "\xe2\x96\x88   \xe2\x96\x88"};
+static const Glyph GLYPH_A = {" \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ", "\xe2\x96\x88   \xe2\x96\x88",
+                               "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", "\xe2\x96\x88   \xe2\x96\x88", "\xe2\x96\x88   \xe2\x96\x88"};
+static const Glyph GLYPH_N = {"\xe2\x96\x88   \xe2\x96\x88", "\xe2\x96\x88\xe2\x96\x88  \xe2\x96\x88", "\xe2\x96\x88 \xe2\x96\x88 \xe2\x96\x88",
+                               "\xe2\x96\x88  \xe2\x96\x88\xe2\x96\x88", "\xe2\x96\x88   \xe2\x96\x88"};
+static const Glyph GLYPH_S = {" \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", "\xe2\x96\x88    ", " \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 ",
+                               "    \xe2\x96\x88", "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 "};
+static const Glyph GLYPH_U = {"\xe2\x96\x88   \xe2\x96\x88", "\xe2\x96\x88   \xe2\x96\x88", "\xe2\x96\x88   \xe2\x96\x88",
+                               "\xe2\x96\x88   \xe2\x96\x88", " \xe2\x96\x88\xe2\x96\x88\xe2\x96\x88 "};
+static const Glyph GLYPH_I = {"\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88", "  \xe2\x96\x88  ", "  \xe2\x96\x88  ",
+                               "  \xe2\x96\x88  ", "\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88\xe2\x96\x88"};
+
+/* T R A N S T U I */
+static const Glyph *const WORD[] = {&GLYPH_T, &GLYPH_R, &GLYPH_A, &GLYPH_N,
+                                     &GLYPH_S, &GLYPH_T, &GLYPH_U, &GLYPH_I};
+#define WORD_LEN (int)(sizeof(WORD) / sizeof(WORD[0]))
+
+#define SPLASH_MS 3000
+
+/* Returns the key that dismissed the splash, or ERR if it timed out on its
+ * own - the caller feeds a real key straight into the normal key handler
+ * instead of silently swallowing it, so e.g. pressing 'g' to skip the
+ * splash also opens settings immediately rather than just landing on the
+ * plain list view. */
+int ui_splash_show(void)
+{
+    int rows, cols;
+    getmaxyx(stdscr, rows, cols);
+
+    int banner_w = WORD_LEN * GLYPH_W + (WORD_LEN - 1) * LETTER_GAP;
+    const char *subtitle = "Transmission TUI Client";
+    const char *hint = "Press any key to continue\xe2\x80\xa6";
+
+    int content_w = banner_w;
+    if ((int)strlen(subtitle) > content_w)
+        content_w = (int)strlen(subtitle);
+    int box_w = content_w + 8;
+    if (box_w > cols - 2)
+        box_w = cols - 2;
+    int box_h = GLYPH_H + 6;
+    if (box_h > rows - 2)
+        box_h = rows - 2;
+    if (box_w < 10 || box_h < 8)
+        return ERR; /* terminal too small to bother */
+
+    int box_y = (rows - box_h) / 2;
+    int box_x = (cols - box_w) / 2;
+
+    erase();
+    WINDOW *win = derwin(stdscr, box_h, box_w, box_y, box_x);
+    ui_box(win, box_h, box_w);
+    ui_box_title(win, box_w, "TransTUI");
+
+    int have_color = has_colors();
+    int banner_y = 2;
+    int banner_x = (box_w - banner_w) / 2;
+    if (banner_x < 1)
+        banner_x = 1;
+
+    int white = have_color ? (COLOR_PAIR(CP_SPLASH_WHITE) | A_BOLD) : A_BOLD;
+
+    wattron(win, white);
+    for (int li = 0; li < WORD_LEN; li++)
+        for (int r = 0; r < GLYPH_H; r++)
+            mvwaddstr(win, banner_y + r, banner_x + li * (GLYPH_W + LETTER_GAP), (*WORD[li])[r]);
+    wattroff(win, white);
+
+    int sub_y = banner_y + GLYPH_H + 1;
+    wattron(win, white);
+    mvwprintw(win, sub_y, (box_w - (int)strlen(subtitle)) / 2, "%s", subtitle);
+    wattroff(win, white);
+
+    int hint_w = (int)strlen(hint) - 2; /* the ellipsis is one 3-byte UTF-8 char, not 3 cells */
+    wattron(win, have_color ? COLOR_PAIR(CP_SPLASH_WHITE) : 0);
+    mvwprintw(win, box_h - 2, (box_w - hint_w) / 2, "%s", hint);
+    wattroff(win, have_color ? COLOR_PAIR(CP_SPLASH_WHITE) : 0);
+
+    wnoutrefresh(win);
+    doupdate();
+    delwin(win);
+
+    struct timespec t0;
+    clock_gettime(CLOCK_MONOTONIC, &t0);
+    timeout(50);
+    int ch = ERR;
+    for (;;) {
+        ch = getch();
+        if (ch != ERR)
+            break;
+        struct timespec now;
+        clock_gettime(CLOCK_MONOTONIC, &now);
+        long elapsed = (now.tv_sec - t0.tv_sec) * 1000 + (now.tv_nsec - t0.tv_nsec) / 1000000;
+        if (elapsed >= SPLASH_MS)
+            break;
+    }
+    return ch;
+}
diff --git a/src/util.c b/src/util.c
new file mode 100644
index 0000000..9824da7
--- /dev/null
+++ b/src/util.c
@@ -0,0 +1,179 @@
+#include "util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+void fmt_size(int64_t bytes, char *buf, size_t n)
+{
+    static const char *units[] = {"B", "KiB", "MiB", "GiB", "TiB", "PiB"};
+    double v = (double)bytes;
+    size_t u = 0;
+    while (v >= 1024.0 && u < 5) {
+        v /= 1024.0;
+        u++;
+    }
+    if (u == 0)
+        snprintf(buf, n, "%.0f %s", v, units[u]);
+    else
+        snprintf(buf, n, "%.2f %s", v, units[u]);
+}
+
+void fmt_speed(int64_t bytes_per_sec, char *buf, size_t n)
+{
+    char tmp[32];
+    fmt_size(bytes_per_sec, tmp, sizeof(tmp));
+    snprintf(buf, n, "%s/s", tmp);
+}
+
+void fmt_eta(int seconds, char *buf, size_t n)
+{
+    if (seconds < 0) {
+        snprintf(buf, n, "\xe2\x88\x9e"); /* infinity, unknown */
+        return;
+    }
+    if (seconds == 0) {
+        snprintf(buf, n, "klar");
+        return;
+    }
+    int days = seconds / 86400;
+    int hours = (seconds % 86400) / 3600;
+    int mins = (seconds % 3600) / 60;
+    int secs = seconds % 60;
+    if (days > 0)
+        snprintf(buf, n, "%dd %dh", days, hours);
+    else if (hours > 0)
+        snprintf(buf, n, "%dh %dm", hours, mins);
+    else if (mins > 0)
+        snprintf(buf, n, "%dm %ds", mins, secs);
+    else
+        snprintf(buf, n, "%ds", secs);
+}
+
+void fmt_ratio(double ratio, char *buf, size_t n)
+{
+    if (ratio < 0)
+        snprintf(buf, n, "\xe2\x88\x9e");
+    else
+        snprintf(buf, n, "%.2f", ratio);
+}
+
+void fmt_time(long unix_time, char *buf, size_t n)
+{
+    if (unix_time <= 0) {
+        snprintf(buf, n, "-");
+        return;
+    }
+    time_t t = (time_t)unix_time;
+    struct tm tmv;
+    localtime_r(&t, &tmv);
+    strftime(buf, n, "%Y-%m-%d %H:%M", &tmv);
+}
+
+static const char b64tab[] =
+    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+
+size_t base64_encoded_size(size_t len)
+{
+    return ((len + 2) / 3) * 4;
+}
+
+long base64_encode(const unsigned char *data, size_t len, char *out, size_t outsize)
+{
+    size_t needed = base64_encoded_size(len);
+    if (outsize < needed + 1)
+        return -1;
+
+    size_t i, o = 0;
+    for (i = 0; i + 2 < len; i += 3) {
+        uint32_t n = ((uint32_t)data[i] << 16) | ((uint32_t)data[i + 1] << 8) | data[i + 2];
+        out[o++] = b64tab[(n >> 18) & 0x3F];
+        out[o++] = b64tab[(n >> 12) & 0x3F];
+        out[o++] = b64tab[(n >> 6) & 0x3F];
+        out[o++] = b64tab[n & 0x3F];
+    }
+    size_t rem = len - i;
+    if (rem == 1) {
+        uint32_t n = (uint32_t)data[i] << 16;
+        out[o++] = b64tab[(n >> 18) & 0x3F];
+        out[o++] = b64tab[(n >> 12) & 0x3F];
+        out[o++] = '=';
+        out[o++] = '=';
+    } else if (rem == 2) {
+        uint32_t n = ((uint32_t)data[i] << 16) | ((uint32_t)data[i + 1] << 8);
+        out[o++] = b64tab[(n >> 18) & 0x3F];
+        out[o++] = b64tab[(n >> 12) & 0x3F];
+        out[o++] = b64tab[(n >> 6) & 0x3F];
+        out[o++] = '=';
+    }
+    out[o] = '\0';
+    return (long)o;
+}
+
+unsigned char *read_file_all(const char *path, size_t *out_len)
+{
+    FILE *f = fopen(path, "rb");
+    if (!f)
+        return NULL;
+    if (fseek(f, 0, SEEK_END) != 0) {
+        fclose(f);
+        return NULL;
+    }
+    long sz = ftell(f);
+    if (sz < 0) {
+        fclose(f);
+        return NULL;
+    }
+    rewind(f);
+    unsigned char *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1);
+    if (!buf) {
+        fclose(f);
+        return NULL;
+    }
+    size_t rd = fread(buf, 1, (size_t)sz, f);
+    fclose(f);
+    if (rd != (size_t)sz) {
+        free(buf);
+        return NULL;
+    }
+    if (out_len)
+        *out_len = rd;
+    return buf;
+}
+
+int str_ends_with(const char *s, const char *suffix)
+{
+    size_t ls = strlen(s), lsuf = strlen(suffix);
+    if (lsuf > ls)
+        return 0;
+    return strcmp(s + (ls - lsuf), suffix) == 0;
+}
+
+int str_starts_with(const char *s, const char *prefix)
+{
+    return strncmp(s, prefix, strlen(prefix)) == 0;
+}
+
+int str_ci_contains(const char *haystack, const char *needle)
+{
+    size_t nlen = strlen(needle);
+    if (nlen == 0)
+        return 1;
+    size_t hlen = strlen(haystack);
+    if (nlen > hlen)
+        return 0;
+    for (size_t i = 0; i + nlen <= hlen; i++) {
+        size_t j = 0;
+        for (; j < nlen; j++) {
+            unsigned char a = (unsigned char)haystack[i + j];
+            unsigned char b = (unsigned char)needle[j];
+            if (tolower(a) != tolower(b))
+                break;
+        }
+        if (j == nlen)
+            return 1;
+    }
+    return 0;
+}
diff --git a/src/util.h b/src/util.h
new file mode 100644
index 0000000..255fba8
--- /dev/null
+++ b/src/util.h
@@ -0,0 +1,26 @@
+#ifndef TRANSTUI_UTIL_H
+#define TRANSTUI_UTIL_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+void fmt_size(int64_t bytes, char *buf, size_t n);
+void fmt_speed(int64_t bytes_per_sec, char *buf, size_t n);
+void fmt_eta(int seconds, char *buf, size_t n);
+void fmt_ratio(double ratio, char *buf, size_t n);
+void fmt_time(long unix_time, char *buf, size_t n);
+
+/* Base64-encodes data into out (NUL-terminated). outsize must be at least
+ * base64_encoded_size(len)+1. Returns encoded length, or -1 if out is too small. */
+size_t base64_encoded_size(size_t len);
+long base64_encode(const unsigned char *data, size_t len, char *out, size_t outsize);
+
+/* Reads an entire file into a malloc'd buffer. Returns NULL on failure. */
+unsigned char *read_file_all(const char *path, size_t *out_len);
+
+int str_ends_with(const char *s, const char *suffix);
+int str_starts_with(const char *s, const char *prefix);
+/* Case-insensitive substring test. Empty needle always matches. */
+int str_ci_contains(const char *haystack, const char *needle);
+
+#endif
diff --git a/third_party/cJSON.c b/third_party/cJSON.c
new file mode 100644
index 0000000..88c2d95
--- /dev/null
+++ b/third_party/cJSON.c
@@ -0,0 +1,3206 @@
+/*
+  Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
+
+  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.
+*/
+
+/* cJSON */
+/* JSON parser in C. */
+
+/* disable warnings about old C89 functions in MSVC */
+#if !defined(_CRT_SECURE_NO_DEPRECATE) && defined(_MSC_VER)
+#define _CRT_SECURE_NO_DEPRECATE
+#endif
+
+#ifdef __GNUC__
+#pragma GCC visibility push(default)
+#endif
+#if defined(_MSC_VER)
+#pragma warning (push)
+/* disable warning about single line comments in system headers */
+#pragma warning (disable : 4001)
+#endif
+
+#include <string.h>
+#include <stdio.h>
+#include <math.h>
+#include <stdlib.h>
+#include <limits.h>
+#include <ctype.h>
+#include <float.h>
+
+#ifdef ENABLE_LOCALES
+#include <locale.h>
+#endif
+
+#if defined(_MSC_VER)
+#pragma warning (pop)
+#endif
+#ifdef __GNUC__
+#pragma GCC visibility pop
+#endif
+
+#include "cJSON.h"
+
+/* define our own boolean type */
+#ifdef true
+#undef true
+#endif
+#define true ((cJSON_bool)1)
+
+#ifdef false
+#undef false
+#endif
+#define false ((cJSON_bool)0)
+
+/* define isnan and isinf for ANSI C, if in C99 or above, isnan and isinf has been defined in math.h */
+#ifndef isinf
+#define isinf(d) (isnan((d - d)) && !isnan(d))
+#endif
+#ifndef isnan
+#define isnan(d) (d != d)
+#endif
+
+#ifndef NAN
+#ifdef _WIN32
+#define NAN sqrt(-1.0)
+#else
+#define NAN 0.0/0.0
+#endif
+#endif
+
+typedef struct {
+    const unsigned char *json;
+    size_t position;
+} error;
+static error global_error = { NULL, 0 };
+
+CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void)
+{
+    return (const char*) (global_error.json + global_error.position);
+}
+
+CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item)
+{
+    if (!cJSON_IsString(item))
+    {
+        return NULL;
+    }
+
+    return item->valuestring;
+}
+
+CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item)
+{
+    if (!cJSON_IsNumber(item))
+    {
+        return (double) NAN;
+    }
+
+    return item->valuedouble;
+}
+
+/* This is a safeguard to prevent copy-pasters from using incompatible C and header files */
+#if (CJSON_VERSION_MAJOR != 1) || (CJSON_VERSION_MINOR != 7) || (CJSON_VERSION_PATCH != 19)
+    #error cJSON.h and cJSON.c have different versions. Make sure that both have the same.
+#endif
+
+CJSON_PUBLIC(const char*) cJSON_Version(void)
+{
+    static char version[15];
+    sprintf(version, "%i.%i.%i", CJSON_VERSION_MAJOR, CJSON_VERSION_MINOR, CJSON_VERSION_PATCH);
+
+    return version;
+}
+
+/* Case insensitive string comparison, doesn't consider two NULL pointers equal though */
+static int case_insensitive_strcmp(const unsigned char *string1, const unsigned char *string2)
+{
+    if ((string1 == NULL) || (string2 == NULL))
+    {
+        return 1;
+    }
+
+    if (string1 == string2)
+    {
+        return 0;
+    }
+
+    for(; tolower(*string1) == tolower(*string2); (void)string1++, string2++)
+    {
+        if (*string1 == '\0')
+        {
+            return 0;
+        }
+    }
+
+    return tolower(*string1) - tolower(*string2);
+}
+
+typedef struct internal_hooks
+{
+    void *(CJSON_CDECL *allocate)(size_t size);
+    void (CJSON_CDECL *deallocate)(void *pointer);
+    void *(CJSON_CDECL *reallocate)(void *pointer, size_t size);
+} internal_hooks;
+
+#if defined(_MSC_VER)
+/* work around MSVC error C2322: '...' address of dllimport '...' is not static */
+static void * CJSON_CDECL internal_malloc(size_t size)
+{
+    return malloc(size);
+}
+static void CJSON_CDECL internal_free(void *pointer)
+{
+    free(pointer);
+}
+static void * CJSON_CDECL internal_realloc(void *pointer, size_t size)
+{
+    return realloc(pointer, size);
+}
+#else
+#define internal_malloc malloc
+#define internal_free free
+#define internal_realloc realloc
+#endif
+
+/* strlen of character literals resolved at compile time */
+#define static_strlen(string_literal) (sizeof(string_literal) - sizeof(""))
+
+static internal_hooks global_hooks = { internal_malloc, internal_free, internal_realloc };
+
+static unsigned char* cJSON_strdup(const unsigned char* string, const internal_hooks * const hooks)
+{
+    size_t length = 0;
+    unsigned char *copy = NULL;
+
+    if (string == NULL)
+    {
+        return NULL;
+    }
+
+    length = strlen((const char*)string) + sizeof("");
+    copy = (unsigned char*)hooks->allocate(length);
+    if (copy == NULL)
+    {
+        return NULL;
+    }
+    memcpy(copy, string, length);
+
+    return copy;
+}
+
+CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks)
+{
+    if (hooks == NULL)
+    {
+        /* Reset hooks */
+        global_hooks.allocate = malloc;
+        global_hooks.deallocate = free;
+        global_hooks.reallocate = realloc;
+        return;
+    }
+
+    global_hooks.allocate = malloc;
+    if (hooks->malloc_fn != NULL)
+    {
+        global_hooks.allocate = hooks->malloc_fn;
+    }
+
+    global_hooks.deallocate = free;
+    if (hooks->free_fn != NULL)
+    {
+        global_hooks.deallocate = hooks->free_fn;
+    }
+
+    /* use realloc only if both free and malloc are used */
+    global_hooks.reallocate = NULL;
+    if ((global_hooks.allocate == malloc) && (global_hooks.deallocate == free))
+    {
+        global_hooks.reallocate = realloc;
+    }
+}
+
+/* Internal constructor. */
+static cJSON *cJSON_New_Item(const internal_hooks * const hooks)
+{
+    cJSON* node = (cJSON*)hooks->allocate(sizeof(cJSON));
+    if (node)
+    {
+        memset(node, '\0', sizeof(cJSON));
+    }
+
+    return node;
+}
+
+/* Delete a cJSON structure. */
+CJSON_PUBLIC(void) cJSON_Delete(cJSON *item)
+{
+    cJSON *next = NULL;
+    while (item != NULL)
+    {
+        next = item->next;
+        if (!(item->type & cJSON_IsReference) && (item->child != NULL))
+        {
+            cJSON_Delete(item->child);
+        }
+        if (!(item->type & cJSON_IsReference) && (item->valuestring != NULL))
+        {
+            global_hooks.deallocate(item->valuestring);
+            item->valuestring = NULL;
+        }
+        if (!(item->type & cJSON_StringIsConst) && (item->string != NULL))
+        {
+            global_hooks.deallocate(item->string);
+            item->string = NULL;
+        }
+        global_hooks.deallocate(item);
+        item = next;
+    }
+}
+
+/* get the decimal point character of the current locale */
+static unsigned char get_decimal_point(void)
+{
+#ifdef ENABLE_LOCALES
+    struct lconv *lconv = localeconv();
+    return (unsigned char) lconv->decimal_point[0];
+#else
+    return '.';
+#endif
+}
+
+typedef struct
+{
+    const unsigned char *content;
+    size_t length;
+    size_t offset;
+    size_t depth; /* How deeply nested (in arrays/objects) is the input at the current offset. */
+    internal_hooks hooks;
+} parse_buffer;
+
+/* check if the given size is left to read in a given parse buffer (starting with 1) */
+#define can_read(buffer, size) ((buffer != NULL) && (((buffer)->offset + size) <= (buffer)->length))
+/* check if the buffer can be accessed at the given index (starting with 0) */
+#define can_access_at_index(buffer, index) ((buffer != NULL) && (((buffer)->offset + index) < (buffer)->length))
+#define cannot_access_at_index(buffer, index) (!can_access_at_index(buffer, index))
+/* get a pointer to the buffer at the position */
+#define buffer_at_offset(buffer) ((buffer)->content + (buffer)->offset)
+
+/* Parse the input text to generate a number, and populate the result into item. */
+static cJSON_bool parse_number(cJSON * const item, parse_buffer * const input_buffer)
+{
+    double number = 0;
+    unsigned char *after_end = NULL;
+    unsigned char *number_c_string;
+    unsigned char decimal_point = get_decimal_point();
+    size_t i = 0;
+    size_t number_string_length = 0;
+    cJSON_bool has_decimal_point = false;
+
+    if ((input_buffer == NULL) || (input_buffer->content == NULL))
+    {
+        return false;
+    }
+
+    /* copy the number into a temporary buffer and replace '.' with the decimal point
+     * of the current locale (for strtod)
+     * This also takes care of '\0' not necessarily being available for marking the end of the input */
+    for (i = 0; can_access_at_index(input_buffer, i); i++)
+    {
+        switch (buffer_at_offset(input_buffer)[i])
+        {
+            case '0':
+            case '1':
+            case '2':
+            case '3':
+            case '4':
+            case '5':
+            case '6':
+            case '7':
+            case '8':
+            case '9':
+            case '+':
+            case '-':
+            case 'e':
+            case 'E':
+                number_string_length++;
+                break;
+
+            case '.':
+                number_string_length++;
+                has_decimal_point = true;
+                break;
+
+            default:
+                goto loop_end;
+        }
+    }
+loop_end:
+    /* malloc for temporary buffer, add 1 for '\0' */
+    number_c_string = (unsigned char *) input_buffer->hooks.allocate(number_string_length + 1);
+    if (number_c_string == NULL)
+    {
+        return false; /* allocation failure */
+    }
+
+    memcpy(number_c_string, buffer_at_offset(input_buffer), number_string_length);
+    number_c_string[number_string_length] = '\0';
+
+    if (has_decimal_point)
+    {
+        for (i = 0; i < number_string_length; i++)
+        {
+            if (number_c_string[i] == '.')
+            {
+                /* replace '.' with the decimal point of the current locale (for strtod) */
+                number_c_string[i] = decimal_point;
+            }
+        }
+    }
+
+    number = strtod((const char*)number_c_string, (char**)&after_end);
+    if (number_c_string == after_end)
+    {
+        /* free the temporary buffer */
+        input_buffer->hooks.deallocate(number_c_string);
+        return false; /* parse_error */
+    }
+
+    item->valuedouble = number;
+
+    /* use saturation in case of overflow */
+    if (number >= INT_MAX)
+    {
+        item->valueint = INT_MAX;
+    }
+    else if (number <= (double)INT_MIN)
+    {
+        item->valueint = INT_MIN;
+    }
+    else
+    {
+        item->valueint = (int)number;
+    }
+
+    item->type = cJSON_Number;
+
+    input_buffer->offset += (size_t)(after_end - number_c_string);
+    /* free the temporary buffer */
+    input_buffer->hooks.deallocate(number_c_string);
+    return true;
+}
+
+/* don't ask me, but the original cJSON_SetNumberValue returns an integer or double */
+CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number)
+{
+    if (object == NULL)
+    {
+        return (double)NAN;
+    }
+
+    if (number >= INT_MAX)
+    {
+        object->valueint = INT_MAX;
+    }
+    else if (number <= (double)INT_MIN)
+    {
+        object->valueint = INT_MIN;
+    }
+    else
+    {
+        object->valueint = (int)number;
+    }
+
+    return object->valuedouble = number;
+}
+
+/* Note: when passing a NULL valuestring, cJSON_SetValuestring treats this as an error and return NULL */
+CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring)
+{
+    char *copy = NULL;
+    size_t v1_len;
+    size_t v2_len;
+    /* if object's type is not cJSON_String or is cJSON_IsReference, it should not set valuestring */
+    if ((object == NULL) || !(object->type & cJSON_String) || (object->type & cJSON_IsReference))
+    {
+        return NULL;
+    }
+    /* return NULL if the object is corrupted or valuestring is NULL */
+    if (object->valuestring == NULL || valuestring == NULL)
+    {
+        return NULL;
+    }
+
+    v1_len = strlen(valuestring);
+    v2_len = strlen(object->valuestring);
+
+    if (v1_len <= v2_len)
+    {
+        /* strcpy does not handle overlapping string: [X1, X2] [Y1, Y2] => X2 < Y1 or Y2 < X1 */
+        if (!( valuestring + v1_len < object->valuestring || object->valuestring + v2_len < valuestring ))
+        {
+            return NULL;
+        }
+        strcpy(object->valuestring, valuestring);
+        return object->valuestring;
+    }
+    copy = (char*) cJSON_strdup((const unsigned char*)valuestring, &global_hooks);
+    if (copy == NULL)
+    {
+        return NULL;
+    }
+    if (object->valuestring != NULL)
+    {
+        cJSON_free(object->valuestring);
+    }
+    object->valuestring = copy;
+
+    return copy;
+}
+
+typedef struct
+{
+    unsigned char *buffer;
+    size_t length;
+    size_t offset;
+    size_t depth; /* current nesting depth (for formatted printing) */
+    cJSON_bool noalloc;
+    cJSON_bool format; /* is this print a formatted print */
+    internal_hooks hooks;
+} printbuffer;
+
+/* realloc printbuffer if necessary to have at least "needed" bytes more */
+static unsigned char* ensure(printbuffer * const p, size_t needed)
+{
+    unsigned char *newbuffer = NULL;
+    size_t newsize = 0;
+
+    if ((p == NULL) || (p->buffer == NULL))
+    {
+        return NULL;
+    }
+
+    if ((p->length > 0) && (p->offset >= p->length))
+    {
+        /* make sure that offset is valid */
+        return NULL;
+    }
+
+    if (needed > INT_MAX)
+    {
+        /* sizes bigger than INT_MAX are currently not supported */
+        return NULL;
+    }
+
+    needed += p->offset + 1;
+    if (needed <= p->length)
+    {
+        return p->buffer + p->offset;
+    }
+
+    if (p->noalloc) {
+        return NULL;
+    }
+
+    /* calculate new buffer size */
+    if (needed > (INT_MAX / 2))
+    {
+        /* overflow of int, use INT_MAX if possible */
+        if (needed <= INT_MAX)
+        {
+            newsize = INT_MAX;
+        }
+        else
+        {
+            return NULL;
+        }
+    }
+    else
+    {
+        newsize = needed * 2;
+    }
+
+    if (p->hooks.reallocate != NULL)
+    {
+        /* reallocate with realloc if available */
+        newbuffer = (unsigned char*)p->hooks.reallocate(p->buffer, newsize);
+        if (newbuffer == NULL)
+        {
+            p->hooks.deallocate(p->buffer);
+            p->length = 0;
+            p->buffer = NULL;
+
+            return NULL;
+        }
+    }
+    else
+    {
+        /* otherwise reallocate manually */
+        newbuffer = (unsigned char*)p->hooks.allocate(newsize);
+        if (!newbuffer)
+        {
+            p->hooks.deallocate(p->buffer);
+            p->length = 0;
+            p->buffer = NULL;
+
+            return NULL;
+        }
+
+        memcpy(newbuffer, p->buffer, p->offset + 1);
+        p->hooks.deallocate(p->buffer);
+    }
+    p->length = newsize;
+    p->buffer = newbuffer;
+
+    return newbuffer + p->offset;
+}
+
+/* calculate the new length of the string in a printbuffer and update the offset */
+static void update_offset(printbuffer * const buffer)
+{
+    const unsigned char *buffer_pointer = NULL;
+    if ((buffer == NULL) || (buffer->buffer == NULL))
+    {
+        return;
+    }
+    buffer_pointer = buffer->buffer + buffer->offset;
+
+    buffer->offset += strlen((const char*)buffer_pointer);
+}
+
+/* securely comparison of floating-point variables */
+static cJSON_bool compare_double(double a, double b)
+{
+    double maxVal = fabs(a) > fabs(b) ? fabs(a) : fabs(b);
+    return (fabs(a - b) <= maxVal * DBL_EPSILON);
+}
+
+/* Render the number nicely from the given item into a string. */
+static cJSON_bool print_number(const cJSON * const item, printbuffer * const output_buffer)
+{
+    unsigned char *output_pointer = NULL;
+    double d = item->valuedouble;
+    int length = 0;
+    size_t i = 0;
+    unsigned char number_buffer[26] = {0}; /* temporary buffer to print the number into */
+    unsigned char decimal_point = get_decimal_point();
+    double test = 0.0;
+
+    if (output_buffer == NULL)
+    {
+        return false;
+    }
+
+    /* This checks for NaN and Infinity */
+    if (isnan(d) || isinf(d))
+    {
+        length = sprintf((char*)number_buffer, "null");
+    }
+    else if(d == (double)item->valueint)
+    {
+        length = sprintf((char*)number_buffer, "%d", item->valueint);
+    }
+    else
+    {
+        /* Try 15 decimal places of precision to avoid nonsignificant nonzero digits */
+        length = sprintf((char*)number_buffer, "%1.15g", d);
+
+        /* Check whether the original double can be recovered */
+        if ((sscanf((char*)number_buffer, "%lg", &test) != 1) || !compare_double((double)test, d))
+        {
+            /* If not, print with 17 decimal places of precision */
+            length = sprintf((char*)number_buffer, "%1.17g", d);
+        }
+    }
+
+    /* sprintf failed or buffer overrun occurred */
+    if ((length < 0) || (length > (int)(sizeof(number_buffer) - 1)))
+    {
+        return false;
+    }
+
+    /* reserve appropriate space in the output */
+    output_pointer = ensure(output_buffer, (size_t)length + sizeof(""));
+    if (output_pointer == NULL)
+    {
+        return false;
+    }
+
+    /* copy the printed number to the output and replace locale
+     * dependent decimal point with '.' */
+    for (i = 0; i < ((size_t)length); i++)
+    {
+        if (number_buffer[i] == decimal_point)
+        {
+            output_pointer[i] = '.';
+            continue;
+        }
+
+        output_pointer[i] = number_buffer[i];
+    }
+    output_pointer[i] = '\0';
+
+    output_buffer->offset += (size_t)length;
+
+    return true;
+}
+
+/* parse 4 digit hexadecimal number */
+static unsigned parse_hex4(const unsigned char * const input)
+{
+    unsigned int h = 0;
+    size_t i = 0;
+
+    for (i = 0; i < 4; i++)
+    {
+        /* parse digit */
+        if ((input[i] >= '0') && (input[i] <= '9'))
+        {
+            h += (unsigned int) input[i] - '0';
+        }
+        else if ((input[i] >= 'A') && (input[i] <= 'F'))
+        {
+            h += (unsigned int) 10 + input[i] - 'A';
+        }
+        else if ((input[i] >= 'a') && (input[i] <= 'f'))
+        {
+            h += (unsigned int) 10 + input[i] - 'a';
+        }
+        else /* invalid */
+        {
+            return 0;
+        }
+
+        if (i < 3)
+        {
+            /* shift left to make place for the next nibble */
+            h = h << 4;
+        }
+    }
+
+    return h;
+}
+
+/* converts a UTF-16 literal to UTF-8
+ * A literal can be one or two sequences of the form \uXXXX */
+static unsigned char utf16_literal_to_utf8(const unsigned char * const input_pointer, const unsigned char * const input_end, unsigned char **output_pointer)
+{
+    long unsigned int codepoint = 0;
+    unsigned int first_code = 0;
+    const unsigned char *first_sequence = input_pointer;
+    unsigned char utf8_length = 0;
+    unsigned char utf8_position = 0;
+    unsigned char sequence_length = 0;
+    unsigned char first_byte_mark = 0;
+
+    if ((input_end - first_sequence) < 6)
+    {
+        /* input ends unexpectedly */
+        goto fail;
+    }
+
+    /* get the first utf16 sequence */
+    first_code = parse_hex4(first_sequence + 2);
+
+    /* check that the code is valid */
+    if (((first_code >= 0xDC00) && (first_code <= 0xDFFF)))
+    {
+        goto fail;
+    }
+
+    /* UTF16 surrogate pair */
+    if ((first_code >= 0xD800) && (first_code <= 0xDBFF))
+    {
+        const unsigned char *second_sequence = first_sequence + 6;
+        unsigned int second_code = 0;
+        sequence_length = 12; /* \uXXXX\uXXXX */
+
+        if ((input_end - second_sequence) < 6)
+        {
+            /* input ends unexpectedly */
+            goto fail;
+        }
+
+        if ((second_sequence[0] != '\\') || (second_sequence[1] != 'u'))
+        {
+            /* missing second half of the surrogate pair */
+            goto fail;
+        }
+
+        /* get the second utf16 sequence */
+        second_code = parse_hex4(second_sequence + 2);
+        /* check that the code is valid */
+        if ((second_code < 0xDC00) || (second_code > 0xDFFF))
+        {
+            /* invalid second half of the surrogate pair */
+            goto fail;
+        }
+
+
+        /* calculate the unicode codepoint from the surrogate pair */
+        codepoint = 0x10000 + (((first_code & 0x3FF) << 10) | (second_code & 0x3FF));
+    }
+    else
+    {
+        sequence_length = 6; /* \uXXXX */
+        codepoint = first_code;
+    }
+
+    /* encode as UTF-8
+     * takes at maximum 4 bytes to encode:
+     * 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx */
+    if (codepoint < 0x80)
+    {
+        /* normal ascii, encoding 0xxxxxxx */
+        utf8_length = 1;
+    }
+    else if (codepoint < 0x800)
+    {
+        /* two bytes, encoding 110xxxxx 10xxxxxx */
+        utf8_length = 2;
+        first_byte_mark = 0xC0; /* 11000000 */
+    }
+    else if (codepoint < 0x10000)
+    {
+        /* three bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx */
+        utf8_length = 3;
+        first_byte_mark = 0xE0; /* 11100000 */
+    }
+    else if (codepoint <= 0x10FFFF)
+    {
+        /* four bytes, encoding 1110xxxx 10xxxxxx 10xxxxxx 10xxxxxx */
+        utf8_length = 4;
+        first_byte_mark = 0xF0; /* 11110000 */
+    }
+    else
+    {
+        /* invalid unicode codepoint */
+        goto fail;
+    }
+
+    /* encode as utf8 */
+    for (utf8_position = (unsigned char)(utf8_length - 1); utf8_position > 0; utf8_position--)
+    {
+        /* 10xxxxxx */
+        (*output_pointer)[utf8_position] = (unsigned char)((codepoint | 0x80) & 0xBF);
+        codepoint >>= 6;
+    }
+    /* encode first byte */
+    if (utf8_length > 1)
+    {
+        (*output_pointer)[0] = (unsigned char)((codepoint | first_byte_mark) & 0xFF);
+    }
+    else
+    {
+        (*output_pointer)[0] = (unsigned char)(codepoint & 0x7F);
+    }
+
+    *output_pointer += utf8_length;
+
+    return sequence_length;
+
+fail:
+    return 0;
+}
+
+/* Parse the input text into an unescaped cinput, and populate item. */
+static cJSON_bool parse_string(cJSON * const item, parse_buffer * const input_buffer)
+{
+    const unsigned char *input_pointer = buffer_at_offset(input_buffer) + 1;
+    const unsigned char *input_end = buffer_at_offset(input_buffer) + 1;
+    unsigned char *output_pointer = NULL;
+    unsigned char *output = NULL;
+
+    /* not a string */
+    if (buffer_at_offset(input_buffer)[0] != '\"')
+    {
+        goto fail;
+    }
+
+    {
+        /* calculate approximate size of the output (overestimate) */
+        size_t allocation_length = 0;
+        size_t skipped_bytes = 0;
+        while (((size_t)(input_end - input_buffer->content) < input_buffer->length) && (*input_end != '\"'))
+        {
+            /* is escape sequence */
+            if (input_end[0] == '\\')
+            {
+                if ((size_t)(input_end + 1 - input_buffer->content) >= input_buffer->length)
+                {
+                    /* prevent buffer overflow when last input character is a backslash */
+                    goto fail;
+                }
+                skipped_bytes++;
+                input_end++;
+            }
+            input_end++;
+        }
+        if (((size_t)(input_end - input_buffer->content) >= input_buffer->length) || (*input_end != '\"'))
+        {
+            goto fail; /* string ended unexpectedly */
+        }
+
+        /* This is at most how much we need for the output */
+        allocation_length = (size_t) (input_end - buffer_at_offset(input_buffer)) - skipped_bytes;
+        output = (unsigned char*)input_buffer->hooks.allocate(allocation_length + sizeof(""));
+        if (output == NULL)
+        {
+            goto fail; /* allocation failure */
+        }
+    }
+
+    output_pointer = output;
+    /* loop through the string literal */
+    while (input_pointer < input_end)
+    {
+        if (*input_pointer != '\\')
+        {
+            *output_pointer++ = *input_pointer++;
+        }
+        /* escape sequence */
+        else
+        {
+            unsigned char sequence_length = 2;
+            if ((input_end - input_pointer) < 1)
+            {
+                goto fail;
+            }
+
+            switch (input_pointer[1])
+            {
+                case 'b':
+                    *output_pointer++ = '\b';
+                    break;
+                case 'f':
+                    *output_pointer++ = '\f';
+                    break;
+                case 'n':
+                    *output_pointer++ = '\n';
+                    break;
+                case 'r':
+                    *output_pointer++ = '\r';
+                    break;
+                case 't':
+                    *output_pointer++ = '\t';
+                    break;
+                case '\"':
+                case '\\':
+                case '/':
+                    *output_pointer++ = input_pointer[1];
+                    break;
+
+                /* UTF-16 literal */
+                case 'u':
+                    sequence_length = utf16_literal_to_utf8(input_pointer, input_end, &output_pointer);
+                    if (sequence_length == 0)
+                    {
+                        /* failed to convert UTF16-literal to UTF-8 */
+                        goto fail;
+                    }
+                    break;
+
+                default:
+                    goto fail;
+            }
+            input_pointer += sequence_length;
+        }
+    }
+
+    /* zero terminate the output */
+    *output_pointer = '\0';
+
+    item->type = cJSON_String;
+    item->valuestring = (char*)output;
+
+    input_buffer->offset = (size_t) (input_end - input_buffer->content);
+    input_buffer->offset++;
+
+    return true;
+
+fail:
+    if (output != NULL)
+    {
+        input_buffer->hooks.deallocate(output);
+        output = NULL;
+    }
+
+    if (input_pointer != NULL)
+    {
+        input_buffer->offset = (size_t)(input_pointer - input_buffer->content);
+    }
+
+    return false;
+}
+
+/* Render the cstring provided to an escaped version that can be printed. */
+static cJSON_bool print_string_ptr(const unsigned char * const input, printbuffer * const output_buffer)
+{
+    const unsigned char *input_pointer = NULL;
+    unsigned char *output = NULL;
+    unsigned char *output_pointer = NULL;
+    size_t output_length = 0;
+    /* numbers of additional characters needed for escaping */
+    size_t escape_characters = 0;
+
+    if (output_buffer == NULL)
+    {
+        return false;
+    }
+
+    /* empty string */
+    if (input == NULL)
+    {
+        output = ensure(output_buffer, sizeof("\"\""));
+        if (output == NULL)
+        {
+            return false;
+        }
+        strcpy((char*)output, "\"\"");
+
+        return true;
+    }
+
+    /* set "flag" to 1 if something needs to be escaped */
+    for (input_pointer = input; *input_pointer; input_pointer++)
+    {
+        switch (*input_pointer)
+        {
+            case '\"':
+            case '\\':
+            case '\b':
+            case '\f':
+            case '\n':
+            case '\r':
+            case '\t':
+                /* one character escape sequence */
+                escape_characters++;
+                break;
+            default:
+                if (*input_pointer < 32)
+                {
+                    /* UTF-16 escape sequence uXXXX */
+                    escape_characters += 5;
+                }
+                break;
+        }
+    }
+    output_length = (size_t)(input_pointer - input) + escape_characters;
+
+    output = ensure(output_buffer, output_length + sizeof("\"\""));
+    if (output == NULL)
+    {
+        return false;
+    }
+
+    /* no characters have to be escaped */
+    if (escape_characters == 0)
+    {
+        output[0] = '\"';
+        memcpy(output + 1, input, output_length);
+        output[output_length + 1] = '\"';
+        output[output_length + 2] = '\0';
+
+        return true;
+    }
+
+    output[0] = '\"';
+    output_pointer = output + 1;
+    /* copy the string */
+    for (input_pointer = input; *input_pointer != '\0'; (void)input_pointer++, output_pointer++)
+    {
+        if ((*input_pointer > 31) && (*input_pointer != '\"') && (*input_pointer != '\\'))
+        {
+            /* normal character, copy */
+            *output_pointer = *input_pointer;
+        }
+        else
+        {
+            /* character needs to be escaped */
+            *output_pointer++ = '\\';
+            switch (*input_pointer)
+            {
+                case '\\':
+                    *output_pointer = '\\';
+                    break;
+                case '\"':
+                    *output_pointer = '\"';
+                    break;
+                case '\b':
+                    *output_pointer = 'b';
+                    break;
+                case '\f':
+                    *output_pointer = 'f';
+                    break;
+                case '\n':
+                    *output_pointer = 'n';
+                    break;
+                case '\r':
+                    *output_pointer = 'r';
+                    break;
+                case '\t':
+                    *output_pointer = 't';
+                    break;
+                default:
+                    /* escape and print as unicode codepoint */
+                    sprintf((char*)output_pointer, "u%04x", *input_pointer);
+                    output_pointer += 4;
+                    break;
+            }
+        }
+    }
+    output[output_length + 1] = '\"';
+    output[output_length + 2] = '\0';
+
+    return true;
+}
+
+/* Invoke print_string_ptr (which is useful) on an item. */
+static cJSON_bool print_string(const cJSON * const item, printbuffer * const p)
+{
+    return print_string_ptr((unsigned char*)item->valuestring, p);
+}
+
+/* Predeclare these prototypes. */
+static cJSON_bool parse_value(cJSON * const item, parse_buffer * const input_buffer);
+static cJSON_bool print_value(const cJSON * const item, printbuffer * const output_buffer);
+static cJSON_bool parse_array(cJSON * const item, parse_buffer * const input_buffer);
+static cJSON_bool print_array(const cJSON * const item, printbuffer * const output_buffer);
+static cJSON_bool parse_object(cJSON * const item, parse_buffer * const input_buffer);
+static cJSON_bool print_object(const cJSON * const item, printbuffer * const output_buffer);
+
+/* Utility to jump whitespace and cr/lf */
+static parse_buffer *buffer_skip_whitespace(parse_buffer * const buffer)
+{
+    if ((buffer == NULL) || (buffer->content == NULL))
+    {
+        return NULL;
+    }
+
+    if (cannot_access_at_index(buffer, 0))
+    {
+        return buffer;
+    }
+
+    while (can_access_at_index(buffer, 0) && (buffer_at_offset(buffer)[0] <= 32))
+    {
+       buffer->offset++;
+    }
+
+    if (buffer->offset == buffer->length)
+    {
+        buffer->offset--;
+    }
+
+    return buffer;
+}
+
+/* skip the UTF-8 BOM (byte order mark) if it is at the beginning of a buffer */
+static parse_buffer *skip_utf8_bom(parse_buffer * const buffer)
+{
+    if ((buffer == NULL) || (buffer->content == NULL) || (buffer->offset != 0))
+    {
+        return NULL;
+    }
+
+    if (can_access_at_index(buffer, 4) && (strncmp((const char*)buffer_at_offset(buffer), "\xEF\xBB\xBF", 3) == 0))
+    {
+        buffer->offset += 3;
+    }
+
+    return buffer;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated)
+{
+    size_t buffer_length;
+
+    if (NULL == value)
+    {
+        return NULL;
+    }
+
+    /* Adding null character size due to require_null_terminated. */
+    buffer_length = strlen(value) + sizeof("");
+
+    return cJSON_ParseWithLengthOpts(value, buffer_length, return_parse_end, require_null_terminated);
+}
+
+/* Parse an object - create a new root, and populate. */
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated)
+{
+    parse_buffer buffer = { 0, 0, 0, 0, { 0, 0, 0 } };
+    cJSON *item = NULL;
+
+    /* reset error position */
+    global_error.json = NULL;
+    global_error.position = 0;
+
+    if (value == NULL || 0 == buffer_length)
+    {
+        goto fail;
+    }
+
+    buffer.content = (const unsigned char*)value;
+    buffer.length = buffer_length;
+    buffer.offset = 0;
+    buffer.hooks = global_hooks;
+
+    item = cJSON_New_Item(&global_hooks);
+    if (item == NULL) /* memory fail */
+    {
+        goto fail;
+    }
+
+    if (!parse_value(item, buffer_skip_whitespace(skip_utf8_bom(&buffer))))
+    {
+        /* parse failure. ep is set. */
+        goto fail;
+    }
+
+    /* if we require null-terminated JSON without appended garbage, skip and then check for a null terminator */
+    if (require_null_terminated)
+    {
+        buffer_skip_whitespace(&buffer);
+        if ((buffer.offset >= buffer.length) || buffer_at_offset(&buffer)[0] != '\0')
+        {
+            goto fail;
+        }
+    }
+    if (return_parse_end)
+    {
+        *return_parse_end = (const char*)buffer_at_offset(&buffer);
+    }
+
+    return item;
+
+fail:
+    if (item != NULL)
+    {
+        cJSON_Delete(item);
+    }
+
+    if (value != NULL)
+    {
+        error local_error;
+        local_error.json = (const unsigned char*)value;
+        local_error.position = 0;
+
+        if (buffer.offset < buffer.length)
+        {
+            local_error.position = buffer.offset;
+        }
+        else if (buffer.length > 0)
+        {
+            local_error.position = buffer.length - 1;
+        }
+
+        if (return_parse_end != NULL)
+        {
+            *return_parse_end = (const char*)local_error.json + local_error.position;
+        }
+
+        global_error = local_error;
+    }
+
+    return NULL;
+}
+
+/* Default options for cJSON_Parse */
+CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value)
+{
+    return cJSON_ParseWithOpts(value, 0, 0);
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length)
+{
+    return cJSON_ParseWithLengthOpts(value, buffer_length, 0, 0);
+}
+
+#define cjson_min(a, b) (((a) < (b)) ? (a) : (b))
+
+static unsigned char *print(const cJSON * const item, cJSON_bool format, const internal_hooks * const hooks)
+{
+    static const size_t default_buffer_size = 256;
+    printbuffer buffer[1];
+    unsigned char *printed = NULL;
+
+    memset(buffer, 0, sizeof(buffer));
+
+    /* create buffer */
+    buffer->buffer = (unsigned char*) hooks->allocate(default_buffer_size);
+    buffer->length = default_buffer_size;
+    buffer->format = format;
+    buffer->hooks = *hooks;
+    if (buffer->buffer == NULL)
+    {
+        goto fail;
+    }
+
+    /* print the value */
+    if (!print_value(item, buffer))
+    {
+        goto fail;
+    }
+    update_offset(buffer);
+
+    /* check if reallocate is available */
+    if (hooks->reallocate != NULL)
+    {
+        printed = (unsigned char*) hooks->reallocate(buffer->buffer, buffer->offset + 1);
+        if (printed == NULL) {
+            goto fail;
+        }
+        buffer->buffer = NULL;
+    }
+    else /* otherwise copy the JSON over to a new buffer */
+    {
+        printed = (unsigned char*) hooks->allocate(buffer->offset + 1);
+        if (printed == NULL)
+        {
+            goto fail;
+        }
+        memcpy(printed, buffer->buffer, cjson_min(buffer->length, buffer->offset + 1));
+        printed[buffer->offset] = '\0'; /* just to be sure */
+
+        /* free the buffer */
+        hooks->deallocate(buffer->buffer);
+        buffer->buffer = NULL;
+    }
+
+    return printed;
+
+fail:
+    if (buffer->buffer != NULL)
+    {
+        hooks->deallocate(buffer->buffer);
+        buffer->buffer = NULL;
+    }
+
+    if (printed != NULL)
+    {
+        hooks->deallocate(printed);
+        printed = NULL;
+    }
+
+    return NULL;
+}
+
+/* Render a cJSON item/entity/structure to text. */
+CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item)
+{
+    return (char*)print(item, true, &global_hooks);
+}
+
+CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item)
+{
+    return (char*)print(item, false, &global_hooks);
+}
+
+CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt)
+{
+    printbuffer p = { 0, 0, 0, 0, 0, 0, { 0, 0, 0 } };
+
+    if (prebuffer < 0)
+    {
+        return NULL;
+    }
+
+    p.buffer = (unsigned char*)global_hooks.allocate((size_t)prebuffer);
+    if (!p.buffer)
+    {
+        return NULL;
+    }
+
+    p.length = (size_t)prebuffer;
+    p.offset = 0;
+    p.noalloc = false;
+    p.format = fmt;
+    p.hooks = global_hooks;
+
+    if (!print_value(item, &p))
+    {
+        global_hooks.deallocate(p.buffer);
+        p.buffer = NULL;
+        return NULL;
+    }
+
+    return (char*)p.buffer;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format)
+{
+    printbuffer p = { 0, 0, 0, 0, 0, 0, { 0, 0, 0 } };
+
+    if ((length < 0) || (buffer == NULL))
+    {
+        return false;
+    }
+
+    p.buffer = (unsigned char*)buffer;
+    p.length = (size_t)length;
+    p.offset = 0;
+    p.noalloc = true;
+    p.format = format;
+    p.hooks = global_hooks;
+
+    return print_value(item, &p);
+}
+
+/* Parser core - when encountering text, process appropriately. */
+static cJSON_bool parse_value(cJSON * const item, parse_buffer * const input_buffer)
+{
+    if ((input_buffer == NULL) || (input_buffer->content == NULL))
+    {
+        return false; /* no input */
+    }
+
+    /* parse the different types of values */
+    /* null */
+    if (can_read(input_buffer, 4) && (strncmp((const char*)buffer_at_offset(input_buffer), "null", 4) == 0))
+    {
+        item->type = cJSON_NULL;
+        input_buffer->offset += 4;
+        return true;
+    }
+    /* false */
+    if (can_read(input_buffer, 5) && (strncmp((const char*)buffer_at_offset(input_buffer), "false", 5) == 0))
+    {
+        item->type = cJSON_False;
+        input_buffer->offset += 5;
+        return true;
+    }
+    /* true */
+    if (can_read(input_buffer, 4) && (strncmp((const char*)buffer_at_offset(input_buffer), "true", 4) == 0))
+    {
+        item->type = cJSON_True;
+        item->valueint = 1;
+        input_buffer->offset += 4;
+        return true;
+    }
+    /* string */
+    if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '\"'))
+    {
+        return parse_string(item, input_buffer);
+    }
+    /* number */
+    if (can_access_at_index(input_buffer, 0) && ((buffer_at_offset(input_buffer)[0] == '-') || ((buffer_at_offset(input_buffer)[0] >= '0') && (buffer_at_offset(input_buffer)[0] <= '9'))))
+    {
+        return parse_number(item, input_buffer);
+    }
+    /* array */
+    if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '['))
+    {
+        return parse_array(item, input_buffer);
+    }
+    /* object */
+    if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '{'))
+    {
+        return parse_object(item, input_buffer);
+    }
+
+    return false;
+}
+
+/* Render a value to text. */
+static cJSON_bool print_value(const cJSON * const item, printbuffer * const output_buffer)
+{
+    unsigned char *output = NULL;
+
+    if ((item == NULL) || (output_buffer == NULL))
+    {
+        return false;
+    }
+
+    switch ((item->type) & 0xFF)
+    {
+        case cJSON_NULL:
+            output = ensure(output_buffer, 5);
+            if (output == NULL)
+            {
+                return false;
+            }
+            strcpy((char*)output, "null");
+            return true;
+
+        case cJSON_False:
+            output = ensure(output_buffer, 6);
+            if (output == NULL)
+            {
+                return false;
+            }
+            strcpy((char*)output, "false");
+            return true;
+
+        case cJSON_True:
+            output = ensure(output_buffer, 5);
+            if (output == NULL)
+            {
+                return false;
+            }
+            strcpy((char*)output, "true");
+            return true;
+
+        case cJSON_Number:
+            return print_number(item, output_buffer);
+
+        case cJSON_Raw:
+        {
+            size_t raw_length = 0;
+            if (item->valuestring == NULL)
+            {
+                return false;
+            }
+
+            raw_length = strlen(item->valuestring) + sizeof("");
+            output = ensure(output_buffer, raw_length);
+            if (output == NULL)
+            {
+                return false;
+            }
+            memcpy(output, item->valuestring, raw_length);
+            return true;
+        }
+
+        case cJSON_String:
+            return print_string(item, output_buffer);
+
+        case cJSON_Array:
+            return print_array(item, output_buffer);
+
+        case cJSON_Object:
+            return print_object(item, output_buffer);
+
+        default:
+            return false;
+    }
+}
+
+/* Build an array from input text. */
+static cJSON_bool parse_array(cJSON * const item, parse_buffer * const input_buffer)
+{
+    cJSON *head = NULL; /* head of the linked list */
+    cJSON *current_item = NULL;
+
+    if (input_buffer->depth >= CJSON_NESTING_LIMIT)
+    {
+        return false; /* to deeply nested */
+    }
+    input_buffer->depth++;
+
+    if (buffer_at_offset(input_buffer)[0] != '[')
+    {
+        /* not an array */
+        goto fail;
+    }
+
+    input_buffer->offset++;
+    buffer_skip_whitespace(input_buffer);
+    if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ']'))
+    {
+        /* empty array */
+        goto success;
+    }
+
+    /* check if we skipped to the end of the buffer */
+    if (cannot_access_at_index(input_buffer, 0))
+    {
+        input_buffer->offset--;
+        goto fail;
+    }
+
+    /* step back to character in front of the first element */
+    input_buffer->offset--;
+    /* loop through the comma separated array elements */
+    do
+    {
+        /* allocate next item */
+        cJSON *new_item = cJSON_New_Item(&(input_buffer->hooks));
+        if (new_item == NULL)
+        {
+            goto fail; /* allocation failure */
+        }
+
+        /* attach next item to list */
+        if (head == NULL)
+        {
+            /* start the linked list */
+            current_item = head = new_item;
+        }
+        else
+        {
+            /* add to the end and advance */
+            current_item->next = new_item;
+            new_item->prev = current_item;
+            current_item = new_item;
+        }
+
+        /* parse next value */
+        input_buffer->offset++;
+        buffer_skip_whitespace(input_buffer);
+        if (!parse_value(current_item, input_buffer))
+        {
+            goto fail; /* failed to parse value */
+        }
+        buffer_skip_whitespace(input_buffer);
+    }
+    while (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ','));
+
+    if (cannot_access_at_index(input_buffer, 0) || buffer_at_offset(input_buffer)[0] != ']')
+    {
+        goto fail; /* expected end of array */
+    }
+
+success:
+    input_buffer->depth--;
+
+    if (head != NULL) {
+        head->prev = current_item;
+    }
+
+    item->type = cJSON_Array;
+    item->child = head;
+
+    input_buffer->offset++;
+
+    return true;
+
+fail:
+    if (head != NULL)
+    {
+        cJSON_Delete(head);
+    }
+
+    return false;
+}
+
+/* Render an array to text */
+static cJSON_bool print_array(const cJSON * const item, printbuffer * const output_buffer)
+{
+    unsigned char *output_pointer = NULL;
+    size_t length = 0;
+    cJSON *current_element = item->child;
+
+    if (output_buffer == NULL)
+    {
+        return false;
+    }
+
+    if (output_buffer->depth >= CJSON_NESTING_LIMIT)
+    {
+        return false; /* nesting is too deep */
+    }
+
+    /* Compose the output array. */
+    /* opening square bracket */
+    output_pointer = ensure(output_buffer, 1);
+    if (output_pointer == NULL)
+    {
+        return false;
+    }
+
+    *output_pointer = '[';
+    output_buffer->offset++;
+    output_buffer->depth++;
+
+    while (current_element != NULL)
+    {
+        if (!print_value(current_element, output_buffer))
+        {
+            return false;
+        }
+        update_offset(output_buffer);
+        if (current_element->next)
+        {
+            length = (size_t) (output_buffer->format ? 2 : 1);
+            output_pointer = ensure(output_buffer, length + 1);
+            if (output_pointer == NULL)
+            {
+                return false;
+            }
+            *output_pointer++ = ',';
+            if(output_buffer->format)
+            {
+                *output_pointer++ = ' ';
+            }
+            *output_pointer = '\0';
+            output_buffer->offset += length;
+        }
+        current_element = current_element->next;
+    }
+
+    output_pointer = ensure(output_buffer, 2);
+    if (output_pointer == NULL)
+    {
+        return false;
+    }
+    *output_pointer++ = ']';
+    *output_pointer = '\0';
+    output_buffer->depth--;
+
+    return true;
+}
+
+/* Build an object from the text. */
+static cJSON_bool parse_object(cJSON * const item, parse_buffer * const input_buffer)
+{
+    cJSON *head = NULL; /* linked list head */
+    cJSON *current_item = NULL;
+
+    if (input_buffer->depth >= CJSON_NESTING_LIMIT)
+    {
+        return false; /* to deeply nested */
+    }
+    input_buffer->depth++;
+
+    if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != '{'))
+    {
+        goto fail; /* not an object */
+    }
+
+    input_buffer->offset++;
+    buffer_skip_whitespace(input_buffer);
+    if (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == '}'))
+    {
+        goto success; /* empty object */
+    }
+
+    /* check if we skipped to the end of the buffer */
+    if (cannot_access_at_index(input_buffer, 0))
+    {
+        input_buffer->offset--;
+        goto fail;
+    }
+
+    /* step back to character in front of the first element */
+    input_buffer->offset--;
+    /* loop through the comma separated array elements */
+    do
+    {
+        /* allocate next item */
+        cJSON *new_item = cJSON_New_Item(&(input_buffer->hooks));
+        if (new_item == NULL)
+        {
+            goto fail; /* allocation failure */
+        }
+
+        /* attach next item to list */
+        if (head == NULL)
+        {
+            /* start the linked list */
+            current_item = head = new_item;
+        }
+        else
+        {
+            /* add to the end and advance */
+            current_item->next = new_item;
+            new_item->prev = current_item;
+            current_item = new_item;
+        }
+
+        if (cannot_access_at_index(input_buffer, 1))
+        {
+            goto fail; /* nothing comes after the comma */
+        }
+
+        /* parse the name of the child */
+        input_buffer->offset++;
+        buffer_skip_whitespace(input_buffer);
+        if (!parse_string(current_item, input_buffer))
+        {
+            goto fail; /* failed to parse name */
+        }
+        buffer_skip_whitespace(input_buffer);
+
+        /* swap valuestring and string, because we parsed the name */
+        current_item->string = current_item->valuestring;
+        current_item->valuestring = NULL;
+
+        if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != ':'))
+        {
+            goto fail; /* invalid object */
+        }
+
+        /* parse the value */
+        input_buffer->offset++;
+        buffer_skip_whitespace(input_buffer);
+        if (!parse_value(current_item, input_buffer))
+        {
+            goto fail; /* failed to parse value */
+        }
+        buffer_skip_whitespace(input_buffer);
+    }
+    while (can_access_at_index(input_buffer, 0) && (buffer_at_offset(input_buffer)[0] == ','));
+
+    if (cannot_access_at_index(input_buffer, 0) || (buffer_at_offset(input_buffer)[0] != '}'))
+    {
+        goto fail; /* expected end of object */
+    }
+
+success:
+    input_buffer->depth--;
+
+    if (head != NULL) {
+        head->prev = current_item;
+    }
+
+    item->type = cJSON_Object;
+    item->child = head;
+
+    input_buffer->offset++;
+    return true;
+
+fail:
+    if (head != NULL)
+    {
+        cJSON_Delete(head);
+    }
+
+    return false;
+}
+
+/* Render an object to text. */
+static cJSON_bool print_object(const cJSON * const item, printbuffer * const output_buffer)
+{
+    unsigned char *output_pointer = NULL;
+    size_t length = 0;
+    cJSON *current_item = item->child;
+
+    if (output_buffer == NULL)
+    {
+        return false;
+    }
+
+    if (output_buffer->depth >= CJSON_NESTING_LIMIT)
+    {
+        return false; /* nesting is too deep */
+    }
+
+    /* Compose the output: */
+    length = (size_t) (output_buffer->format ? 2 : 1); /* fmt: {\n */
+    output_pointer = ensure(output_buffer, length + 1);
+    if (output_pointer == NULL)
+    {
+        return false;
+    }
+
+    *output_pointer++ = '{';
+    output_buffer->depth++;
+    if (output_buffer->format)
+    {
+        *output_pointer++ = '\n';
+    }
+    output_buffer->offset += length;
+
+    while (current_item)
+    {
+        if (output_buffer->format)
+        {
+            size_t i;
+            output_pointer = ensure(output_buffer, output_buffer->depth);
+            if (output_pointer == NULL)
+            {
+                return false;
+            }
+            for (i = 0; i < output_buffer->depth; i++)
+            {
+                *output_pointer++ = '\t';
+            }
+            output_buffer->offset += output_buffer->depth;
+        }
+
+        /* print key */
+        if (!print_string_ptr((unsigned char*)current_item->string, output_buffer))
+        {
+            return false;
+        }
+        update_offset(output_buffer);
+
+        length = (size_t) (output_buffer->format ? 2 : 1);
+        output_pointer = ensure(output_buffer, length);
+        if (output_pointer == NULL)
+        {
+            return false;
+        }
+        *output_pointer++ = ':';
+        if (output_buffer->format)
+        {
+            *output_pointer++ = '\t';
+        }
+        output_buffer->offset += length;
+
+        /* print value */
+        if (!print_value(current_item, output_buffer))
+        {
+            return false;
+        }
+        update_offset(output_buffer);
+
+        /* print comma if not last */
+        length = ((size_t)(output_buffer->format ? 1 : 0) + (size_t)(current_item->next ? 1 : 0));
+        output_pointer = ensure(output_buffer, length + 1);
+        if (output_pointer == NULL)
+        {
+            return false;
+        }
+        if (current_item->next)
+        {
+            *output_pointer++ = ',';
+        }
+
+        if (output_buffer->format)
+        {
+            *output_pointer++ = '\n';
+        }
+        *output_pointer = '\0';
+        output_buffer->offset += length;
+
+        current_item = current_item->next;
+    }
+
+    output_pointer = ensure(output_buffer, output_buffer->format ? (output_buffer->depth + 1) : 2);
+    if (output_pointer == NULL)
+    {
+        return false;
+    }
+    if (output_buffer->format)
+    {
+        size_t i;
+        for (i = 0; i < (output_buffer->depth - 1); i++)
+        {
+            *output_pointer++ = '\t';
+        }
+    }
+    *output_pointer++ = '}';
+    *output_pointer = '\0';
+    output_buffer->depth--;
+
+    return true;
+}
+
+/* Get Array size/item / object item. */
+CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array)
+{
+    cJSON *child = NULL;
+    size_t size = 0;
+
+    if (array == NULL)
+    {
+        return 0;
+    }
+
+    child = array->child;
+
+    while(child != NULL)
+    {
+        size++;
+        child = child->next;
+    }
+
+    /* FIXME: Can overflow here. Cannot be fixed without breaking the API */
+
+    return (int)size;
+}
+
+static cJSON* get_array_item(const cJSON *array, size_t index)
+{
+    cJSON *current_child = NULL;
+
+    if (array == NULL)
+    {
+        return NULL;
+    }
+
+    current_child = array->child;
+    while ((current_child != NULL) && (index > 0))
+    {
+        index--;
+        current_child = current_child->next;
+    }
+
+    return current_child;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index)
+{
+    if (index < 0)
+    {
+        return NULL;
+    }
+
+    return get_array_item(array, (size_t)index);
+}
+
+static cJSON *get_object_item(const cJSON * const object, const char * const name, const cJSON_bool case_sensitive)
+{
+    cJSON *current_element = NULL;
+
+    if ((object == NULL) || (name == NULL))
+    {
+        return NULL;
+    }
+
+    current_element = object->child;
+    if (case_sensitive)
+    {
+        while ((current_element != NULL) && (current_element->string != NULL) && (strcmp(name, current_element->string) != 0))
+        {
+            current_element = current_element->next;
+        }
+    }
+    else
+    {
+        while ((current_element != NULL) && (case_insensitive_strcmp((const unsigned char*)name, (const unsigned char*)(current_element->string)) != 0))
+        {
+            current_element = current_element->next;
+        }
+    }
+
+    if ((current_element == NULL) || (current_element->string == NULL)) {
+        return NULL;
+    }
+
+    return current_element;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string)
+{
+    return get_object_item(object, string, false);
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string)
+{
+    return get_object_item(object, string, true);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string)
+{
+    return cJSON_GetObjectItem(object, string) ? 1 : 0;
+}
+
+/* Utility for array list handling. */
+static void suffix_object(cJSON *prev, cJSON *item)
+{
+    prev->next = item;
+    item->prev = prev;
+}
+
+/* Utility for handling references. */
+static cJSON *create_reference(const cJSON *item, const internal_hooks * const hooks)
+{
+    cJSON *reference = NULL;
+    if (item == NULL)
+    {
+        return NULL;
+    }
+
+    reference = cJSON_New_Item(hooks);
+    if (reference == NULL)
+    {
+        return NULL;
+    }
+
+    memcpy(reference, item, sizeof(cJSON));
+    reference->string = NULL;
+    reference->type |= cJSON_IsReference;
+    reference->next = reference->prev = NULL;
+    return reference;
+}
+
+static cJSON_bool add_item_to_array(cJSON *array, cJSON *item)
+{
+    cJSON *child = NULL;
+
+    if ((item == NULL) || (array == NULL) || (array == item))
+    {
+        return false;
+    }
+
+    child = array->child;
+    /*
+     * To find the last item in array quickly, we use prev in array
+     */
+    if (child == NULL)
+    {
+        /* list is empty, start new one */
+        array->child = item;
+        item->prev = item;
+        item->next = NULL;
+    }
+    else
+    {
+        /* append to the end */
+        if (child->prev)
+        {
+            suffix_object(child->prev, item);
+            array->child->prev = item;
+        }
+    }
+
+    return true;
+}
+
+/* Add item to array/object. */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item)
+{
+    return add_item_to_array(array, item);
+}
+
+#if defined(__clang__) || (defined(__GNUC__)  && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5))))
+    #pragma GCC diagnostic push
+#endif
+#ifdef __GNUC__
+#pragma GCC diagnostic ignored "-Wcast-qual"
+#endif
+/* helper function to cast away const */
+static void* cast_away_const(const void* string)
+{
+    return (void*)string;
+}
+#if defined(__clang__) || (defined(__GNUC__)  && ((__GNUC__ > 4) || ((__GNUC__ == 4) && (__GNUC_MINOR__ > 5))))
+    #pragma GCC diagnostic pop
+#endif
+
+
+static cJSON_bool add_item_to_object(cJSON * const object, const char * const string, cJSON * const item, const internal_hooks * const hooks, const cJSON_bool constant_key)
+{
+    char *new_key = NULL;
+    int new_type = cJSON_Invalid;
+
+    if ((object == NULL) || (string == NULL) || (item == NULL) || (object == item))
+    {
+        return false;
+    }
+
+    if (constant_key)
+    {
+        new_key = (char*)cast_away_const(string);
+        new_type = item->type | cJSON_StringIsConst;
+    }
+    else
+    {
+        new_key = (char*)cJSON_strdup((const unsigned char*)string, hooks);
+        if (new_key == NULL)
+        {
+            return false;
+        }
+
+        new_type = item->type & ~cJSON_StringIsConst;
+    }
+
+    if (!(item->type & cJSON_StringIsConst) && (item->string != NULL))
+    {
+        hooks->deallocate(item->string);
+    }
+
+    item->string = new_key;
+    item->type = new_type;
+
+    return add_item_to_array(object, item);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item)
+{
+    return add_item_to_object(object, string, item, &global_hooks, false);
+}
+
+/* Add an item to an object with constant string as key */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item)
+{
+    return add_item_to_object(object, string, item, &global_hooks, true);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item)
+{
+    if (array == NULL)
+    {
+        return false;
+    }
+
+    return add_item_to_array(array, create_reference(item, &global_hooks));
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item)
+{
+    if ((object == NULL) || (string == NULL))
+    {
+        return false;
+    }
+
+    return add_item_to_object(object, string, create_reference(item, &global_hooks), &global_hooks, false);
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name)
+{
+    cJSON *null = cJSON_CreateNull();
+    if (add_item_to_object(object, name, null, &global_hooks, false))
+    {
+        return null;
+    }
+
+    cJSON_Delete(null);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name)
+{
+    cJSON *true_item = cJSON_CreateTrue();
+    if (add_item_to_object(object, name, true_item, &global_hooks, false))
+    {
+        return true_item;
+    }
+
+    cJSON_Delete(true_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name)
+{
+    cJSON *false_item = cJSON_CreateFalse();
+    if (add_item_to_object(object, name, false_item, &global_hooks, false))
+    {
+        return false_item;
+    }
+
+    cJSON_Delete(false_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean)
+{
+    cJSON *bool_item = cJSON_CreateBool(boolean);
+    if (add_item_to_object(object, name, bool_item, &global_hooks, false))
+    {
+        return bool_item;
+    }
+
+    cJSON_Delete(bool_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number)
+{
+    cJSON *number_item = cJSON_CreateNumber(number);
+    if (add_item_to_object(object, name, number_item, &global_hooks, false))
+    {
+        return number_item;
+    }
+
+    cJSON_Delete(number_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string)
+{
+    cJSON *string_item = cJSON_CreateString(string);
+    if (add_item_to_object(object, name, string_item, &global_hooks, false))
+    {
+        return string_item;
+    }
+
+    cJSON_Delete(string_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw)
+{
+    cJSON *raw_item = cJSON_CreateRaw(raw);
+    if (add_item_to_object(object, name, raw_item, &global_hooks, false))
+    {
+        return raw_item;
+    }
+
+    cJSON_Delete(raw_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name)
+{
+    cJSON *object_item = cJSON_CreateObject();
+    if (add_item_to_object(object, name, object_item, &global_hooks, false))
+    {
+        return object_item;
+    }
+
+    cJSON_Delete(object_item);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name)
+{
+    cJSON *array = cJSON_CreateArray();
+    if (add_item_to_object(object, name, array, &global_hooks, false))
+    {
+        return array;
+    }
+
+    cJSON_Delete(array);
+    return NULL;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item)
+{
+    if ((parent == NULL) || (item == NULL) || (item != parent->child && item->prev == NULL))
+    {
+        return NULL;
+    }
+
+    if (item != parent->child)
+    {
+        /* not the first element */
+        item->prev->next = item->next;
+    }
+    if (item->next != NULL)
+    {
+        /* not the last element */
+        item->next->prev = item->prev;
+    }
+
+    if (item == parent->child)
+    {
+        /* first element */
+        parent->child = item->next;
+    }
+    else if (item->next == NULL)
+    {
+        /* last element */
+        parent->child->prev = item->prev;
+    }
+
+    /* make sure the detached item doesn't point anywhere anymore */
+    item->prev = NULL;
+    item->next = NULL;
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which)
+{
+    if (which < 0)
+    {
+        return NULL;
+    }
+
+    return cJSON_DetachItemViaPointer(array, get_array_item(array, (size_t)which));
+}
+
+CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which)
+{
+    cJSON_Delete(cJSON_DetachItemFromArray(array, which));
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string)
+{
+    cJSON *to_detach = cJSON_GetObjectItem(object, string);
+
+    return cJSON_DetachItemViaPointer(object, to_detach);
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string)
+{
+    cJSON *to_detach = cJSON_GetObjectItemCaseSensitive(object, string);
+
+    return cJSON_DetachItemViaPointer(object, to_detach);
+}
+
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string)
+{
+    cJSON_Delete(cJSON_DetachItemFromObject(object, string));
+}
+
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string)
+{
+    cJSON_Delete(cJSON_DetachItemFromObjectCaseSensitive(object, string));
+}
+
+/* Replace array/object items with new ones. */
+CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem)
+{
+    cJSON *after_inserted = NULL;
+
+    if (which < 0 || newitem == NULL)
+    {
+        return false;
+    }
+
+    after_inserted = get_array_item(array, (size_t)which);
+    if (after_inserted == NULL)
+    {
+        return add_item_to_array(array, newitem);
+    }
+
+    if (after_inserted != array->child && after_inserted->prev == NULL) {
+        /* return false if after_inserted is a corrupted array item */
+        return false;
+    }
+
+    newitem->next = after_inserted;
+    newitem->prev = after_inserted->prev;
+    after_inserted->prev = newitem;
+    if (after_inserted == array->child)
+    {
+        array->child = newitem;
+    }
+    else
+    {
+        newitem->prev->next = newitem;
+    }
+    return true;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement)
+{
+    if ((parent == NULL) || (parent->child == NULL) || (replacement == NULL) || (item == NULL))
+    {
+        return false;
+    }
+
+    if (replacement == item)
+    {
+        return true;
+    }
+
+    replacement->next = item->next;
+    replacement->prev = item->prev;
+
+    if (replacement->next != NULL)
+    {
+        replacement->next->prev = replacement;
+    }
+    if (parent->child == item)
+    {
+        if (parent->child->prev == parent->child)
+        {
+            replacement->prev = replacement;
+        }
+        parent->child = replacement;
+    }
+    else
+    {   /*
+         * To find the last item in array quickly, we use prev in array.
+         * We can't modify the last item's next pointer where this item was the parent's child
+         */
+        if (replacement->prev != NULL)
+        {
+            replacement->prev->next = replacement;
+        }
+        if (replacement->next == NULL)
+        {
+            parent->child->prev = replacement;
+        }
+    }
+
+    item->next = NULL;
+    item->prev = NULL;
+    cJSON_Delete(item);
+
+    return true;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem)
+{
+    if (which < 0)
+    {
+        return false;
+    }
+
+    return cJSON_ReplaceItemViaPointer(array, get_array_item(array, (size_t)which), newitem);
+}
+
+static cJSON_bool replace_item_in_object(cJSON *object, const char *string, cJSON *replacement, cJSON_bool case_sensitive)
+{
+    if ((replacement == NULL) || (string == NULL))
+    {
+        return false;
+    }
+
+    /* replace the name in the replacement */
+    if (!(replacement->type & cJSON_StringIsConst) && (replacement->string != NULL))
+    {
+        cJSON_free(replacement->string);
+    }
+    replacement->string = (char*)cJSON_strdup((const unsigned char*)string, &global_hooks);
+    if (replacement->string == NULL)
+    {
+        return false;
+    }
+
+    replacement->type &= ~cJSON_StringIsConst;
+
+    return cJSON_ReplaceItemViaPointer(object, get_object_item(object, string, case_sensitive), replacement);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object, const char *string, cJSON *newitem)
+{
+    return replace_item_in_object(object, string, newitem, false);
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object, const char *string, cJSON *newitem)
+{
+    return replace_item_in_object(object, string, newitem, true);
+}
+
+/* Create basic types: */
+CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = cJSON_NULL;
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = cJSON_True;
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = cJSON_False;
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = boolean ? cJSON_True : cJSON_False;
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = cJSON_Number;
+        item->valuedouble = num;
+
+        /* use saturation in case of overflow */
+        if (num >= INT_MAX)
+        {
+            item->valueint = INT_MAX;
+        }
+        else if (num <= (double)INT_MIN)
+        {
+            item->valueint = INT_MIN;
+        }
+        else
+        {
+            item->valueint = (int)num;
+        }
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = cJSON_String;
+        item->valuestring = (char*)cJSON_strdup((const unsigned char*)string, &global_hooks);
+        if(!item->valuestring)
+        {
+            cJSON_Delete(item);
+            return NULL;
+        }
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if (item != NULL)
+    {
+        item->type = cJSON_String | cJSON_IsReference;
+        item->valuestring = (char*)cast_away_const(string);
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if (item != NULL) {
+        item->type = cJSON_Object | cJSON_IsReference;
+        item->child = (cJSON*)cast_away_const(child);
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child) {
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if (item != NULL) {
+        item->type = cJSON_Array | cJSON_IsReference;
+        item->child = (cJSON*)cast_away_const(child);
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type = cJSON_Raw;
+        item->valuestring = (char*)cJSON_strdup((const unsigned char*)raw, &global_hooks);
+        if(!item->valuestring)
+        {
+            cJSON_Delete(item);
+            return NULL;
+        }
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if(item)
+    {
+        item->type=cJSON_Array;
+    }
+
+    return item;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void)
+{
+    cJSON *item = cJSON_New_Item(&global_hooks);
+    if (item)
+    {
+        item->type = cJSON_Object;
+    }
+
+    return item;
+}
+
+/* Create Arrays: */
+CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count)
+{
+    size_t i = 0;
+    cJSON *n = NULL;
+    cJSON *p = NULL;
+    cJSON *a = NULL;
+
+    if ((count < 0) || (numbers == NULL))
+    {
+        return NULL;
+    }
+
+    a = cJSON_CreateArray();
+
+    for(i = 0; a && (i < (size_t)count); i++)
+    {
+        n = cJSON_CreateNumber(numbers[i]);
+        if (!n)
+        {
+            cJSON_Delete(a);
+            return NULL;
+        }
+        if(!i)
+        {
+            a->child = n;
+        }
+        else
+        {
+            suffix_object(p, n);
+        }
+        p = n;
+    }
+
+    if (a && a->child) {
+        a->child->prev = n;
+    }
+
+    return a;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count)
+{
+    size_t i = 0;
+    cJSON *n = NULL;
+    cJSON *p = NULL;
+    cJSON *a = NULL;
+
+    if ((count < 0) || (numbers == NULL))
+    {
+        return NULL;
+    }
+
+    a = cJSON_CreateArray();
+
+    for(i = 0; a && (i < (size_t)count); i++)
+    {
+        n = cJSON_CreateNumber((double)numbers[i]);
+        if(!n)
+        {
+            cJSON_Delete(a);
+            return NULL;
+        }
+        if(!i)
+        {
+            a->child = n;
+        }
+        else
+        {
+            suffix_object(p, n);
+        }
+        p = n;
+    }
+
+    if (a && a->child) {
+        a->child->prev = n;
+    }
+
+    return a;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count)
+{
+    size_t i = 0;
+    cJSON *n = NULL;
+    cJSON *p = NULL;
+    cJSON *a = NULL;
+
+    if ((count < 0) || (numbers == NULL))
+    {
+        return NULL;
+    }
+
+    a = cJSON_CreateArray();
+
+    for(i = 0; a && (i < (size_t)count); i++)
+    {
+        n = cJSON_CreateNumber(numbers[i]);
+        if(!n)
+        {
+            cJSON_Delete(a);
+            return NULL;
+        }
+        if(!i)
+        {
+            a->child = n;
+        }
+        else
+        {
+            suffix_object(p, n);
+        }
+        p = n;
+    }
+
+    if (a && a->child) {
+        a->child->prev = n;
+    }
+
+    return a;
+}
+
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count)
+{
+    size_t i = 0;
+    cJSON *n = NULL;
+    cJSON *p = NULL;
+    cJSON *a = NULL;
+
+    if ((count < 0) || (strings == NULL))
+    {
+        return NULL;
+    }
+
+    a = cJSON_CreateArray();
+
+    for (i = 0; a && (i < (size_t)count); i++)
+    {
+        n = cJSON_CreateString(strings[i]);
+        if(!n)
+        {
+            cJSON_Delete(a);
+            return NULL;
+        }
+        if(!i)
+        {
+            a->child = n;
+        }
+        else
+        {
+            suffix_object(p,n);
+        }
+        p = n;
+    }
+
+    if (a && a->child) {
+        a->child->prev = n;
+    }
+
+    return a;
+}
+
+/* Duplication */
+cJSON * cJSON_Duplicate_rec(const cJSON *item, size_t depth, cJSON_bool recurse);
+
+CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse)
+{
+    return cJSON_Duplicate_rec(item, 0, recurse );
+}
+
+cJSON * cJSON_Duplicate_rec(const cJSON *item, size_t depth, cJSON_bool recurse)
+{
+    cJSON *newitem = NULL;
+    cJSON *child = NULL;
+    cJSON *next = NULL;
+    cJSON *newchild = NULL;
+
+    /* Bail on bad ptr */
+    if (!item)
+    {
+        goto fail;
+    }
+    /* Create new item */
+    newitem = cJSON_New_Item(&global_hooks);
+    if (!newitem)
+    {
+        goto fail;
+    }
+    /* Copy over all vars */
+    newitem->type = item->type & (~cJSON_IsReference);
+    newitem->valueint = item->valueint;
+    newitem->valuedouble = item->valuedouble;
+    if (item->valuestring)
+    {
+        newitem->valuestring = (char*)cJSON_strdup((unsigned char*)item->valuestring, &global_hooks);
+        if (!newitem->valuestring)
+        {
+            goto fail;
+        }
+    }
+    if (item->string)
+    {
+        newitem->string = (item->type&cJSON_StringIsConst) ? item->string : (char*)cJSON_strdup((unsigned char*)item->string, &global_hooks);
+        if (!newitem->string)
+        {
+            goto fail;
+        }
+    }
+    /* If non-recursive, then we're done! */
+    if (!recurse)
+    {
+        return newitem;
+    }
+    /* Walk the ->next chain for the child. */
+    child = item->child;
+    while (child != NULL)
+    {
+        if(depth >= CJSON_CIRCULAR_LIMIT) {
+            goto fail;
+        }
+        newchild = cJSON_Duplicate_rec(child, depth + 1, true); /* Duplicate (with recurse) each item in the ->next chain */
+        if (!newchild)
+        {
+            goto fail;
+        }
+        if (next != NULL)
+        {
+            /* If newitem->child already set, then crosswire ->prev and ->next and move on */
+            next->next = newchild;
+            newchild->prev = next;
+            next = newchild;
+        }
+        else
+        {
+            /* Set newitem->child and move to it */
+            newitem->child = newchild;
+            next = newchild;
+        }
+        child = child->next;
+    }
+    if (newitem && newitem->child)
+    {
+        newitem->child->prev = newchild;
+    }
+
+    return newitem;
+
+fail:
+    if (newitem != NULL)
+    {
+        cJSON_Delete(newitem);
+    }
+
+    return NULL;
+}
+
+static void skip_oneline_comment(char **input)
+{
+    *input += static_strlen("//");
+
+    for (; (*input)[0] != '\0'; ++(*input))
+    {
+        if ((*input)[0] == '\n') {
+            *input += static_strlen("\n");
+            return;
+        }
+    }
+}
+
+static void skip_multiline_comment(char **input)
+{
+    *input += static_strlen("/*");
+
+    for (; (*input)[0] != '\0'; ++(*input))
+    {
+        if (((*input)[0] == '*') && ((*input)[1] == '/'))
+        {
+            *input += static_strlen("*/");
+            return;
+        }
+    }
+}
+
+static void minify_string(char **input, char **output) {
+    (*output)[0] = (*input)[0];
+    *input += static_strlen("\"");
+    *output += static_strlen("\"");
+
+
+    for (; (*input)[0] != '\0'; (void)++(*input), ++(*output)) {
+        (*output)[0] = (*input)[0];
+
+        if ((*input)[0] == '\"') {
+            (*output)[0] = '\"';
+            *input += static_strlen("\"");
+            *output += static_strlen("\"");
+            return;
+        } else if (((*input)[0] == '\\') && ((*input)[1] == '\"')) {
+            (*output)[1] = (*input)[1];
+            *input += static_strlen("\"");
+            *output += static_strlen("\"");
+        }
+    }
+}
+
+CJSON_PUBLIC(void) cJSON_Minify(char *json)
+{
+    char *into = json;
+
+    if (json == NULL)
+    {
+        return;
+    }
+
+    while (json[0] != '\0')
+    {
+        switch (json[0])
+        {
+            case ' ':
+            case '\t':
+            case '\r':
+            case '\n':
+                json++;
+                break;
+
+            case '/':
+                if (json[1] == '/')
+                {
+                    skip_oneline_comment(&json);
+                }
+                else if (json[1] == '*')
+                {
+                    skip_multiline_comment(&json);
+                } else {
+                    json++;
+                }
+                break;
+
+            case '\"':
+                minify_string(&json, (char**)&into);
+                break;
+
+            default:
+                into[0] = json[0];
+                json++;
+                into++;
+        }
+    }
+
+    /* and null-terminate. */
+    *into = '\0';
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_Invalid;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_False;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xff) == cJSON_True;
+}
+
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & (cJSON_True | cJSON_False)) != 0;
+}
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_NULL;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_Number;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_String;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_Array;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_Object;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item)
+{
+    if (item == NULL)
+    {
+        return false;
+    }
+
+    return (item->type & 0xFF) == cJSON_Raw;
+}
+
+CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive)
+{
+    if ((a == NULL) || (b == NULL) || ((a->type & 0xFF) != (b->type & 0xFF)))
+    {
+        return false;
+    }
+
+    /* check if type is valid */
+    switch (a->type & 0xFF)
+    {
+        case cJSON_False:
+        case cJSON_True:
+        case cJSON_NULL:
+        case cJSON_Number:
+        case cJSON_String:
+        case cJSON_Raw:
+        case cJSON_Array:
+        case cJSON_Object:
+            break;
+
+        default:
+            return false;
+    }
+
+    /* identical objects are equal */
+    if (a == b)
+    {
+        return true;
+    }
+
+    switch (a->type & 0xFF)
+    {
+        /* in these cases and equal type is enough */
+        case cJSON_False:
+        case cJSON_True:
+        case cJSON_NULL:
+            return true;
+
+        case cJSON_Number:
+            if (compare_double(a->valuedouble, b->valuedouble))
+            {
+                return true;
+            }
+            return false;
+
+        case cJSON_String:
+        case cJSON_Raw:
+            if ((a->valuestring == NULL) || (b->valuestring == NULL))
+            {
+                return false;
+            }
+            if (strcmp(a->valuestring, b->valuestring) == 0)
+            {
+                return true;
+            }
+
+            return false;
+
+        case cJSON_Array:
+        {
+            cJSON *a_element = a->child;
+            cJSON *b_element = b->child;
+
+            for (; (a_element != NULL) && (b_element != NULL);)
+            {
+                if (!cJSON_Compare(a_element, b_element, case_sensitive))
+                {
+                    return false;
+                }
+
+                a_element = a_element->next;
+                b_element = b_element->next;
+            }
+
+            /* one of the arrays is longer than the other */
+            if (a_element != b_element) {
+                return false;
+            }
+
+            return true;
+        }
+
+        case cJSON_Object:
+        {
+            cJSON *a_element = NULL;
+            cJSON *b_element = NULL;
+            cJSON_ArrayForEach(a_element, a)
+            {
+                /* TODO This has O(n^2) runtime, which is horrible! */
+                b_element = get_object_item(b, a_element->string, case_sensitive);
+                if (b_element == NULL)
+                {
+                    return false;
+                }
+
+                if (!cJSON_Compare(a_element, b_element, case_sensitive))
+                {
+                    return false;
+                }
+            }
+
+            /* doing this twice, once on a and b to prevent true comparison if a subset of b
+             * TODO: Do this the proper way, this is just a fix for now */
+            cJSON_ArrayForEach(b_element, b)
+            {
+                a_element = get_object_item(a, b_element->string, case_sensitive);
+                if (a_element == NULL)
+                {
+                    return false;
+                }
+
+                if (!cJSON_Compare(b_element, a_element, case_sensitive))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        default:
+            return false;
+    }
+}
+
+CJSON_PUBLIC(void *) cJSON_malloc(size_t size)
+{
+    return global_hooks.allocate(size);
+}
+
+CJSON_PUBLIC(void) cJSON_free(void *object)
+{
+    global_hooks.deallocate(object);
+    object = NULL;
+}
diff --git a/third_party/cJSON.h b/third_party/cJSON.h
new file mode 100644
index 0000000..cab5feb
--- /dev/null
+++ b/third_party/cJSON.h
@@ -0,0 +1,306 @@
+/*
+  Copyright (c) 2009-2017 Dave Gamble and cJSON contributors
+
+  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.
+*/
+
+#ifndef cJSON__h
+#define cJSON__h
+
+#ifdef __cplusplus
+extern "C"
+{
+#endif
+
+#if !defined(__WINDOWS__) && (defined(WIN32) || defined(WIN64) || defined(_MSC_VER) || defined(_WIN32))
+#define __WINDOWS__
+#endif
+
+#ifdef __WINDOWS__
+
+/* When compiling for windows, we specify a specific calling convention to avoid issues where we are being called from a project with a different default calling convention.  For windows you have 3 define options:
+
+CJSON_HIDE_SYMBOLS - Define this in the case where you don't want to ever dllexport symbols
+CJSON_EXPORT_SYMBOLS - Define this on library build when you want to dllexport symbols (default)
+CJSON_IMPORT_SYMBOLS - Define this if you want to dllimport symbol
+
+For *nix builds that support visibility attribute, you can define similar behavior by
+
+setting default visibility to hidden by adding
+-fvisibility=hidden (for gcc)
+or
+-xldscope=hidden (for sun cc)
+to CFLAGS
+
+then using the CJSON_API_VISIBILITY flag to "export" the same symbols the way CJSON_EXPORT_SYMBOLS does
+
+*/
+
+#define CJSON_CDECL __cdecl
+#define CJSON_STDCALL __stdcall
+
+/* export symbols by default, this is necessary for copy pasting the C and header file */
+#if !defined(CJSON_HIDE_SYMBOLS) && !defined(CJSON_IMPORT_SYMBOLS) && !defined(CJSON_EXPORT_SYMBOLS)
+#define CJSON_EXPORT_SYMBOLS
+#endif
+
+#if defined(CJSON_HIDE_SYMBOLS)
+#define CJSON_PUBLIC(type)   type CJSON_STDCALL
+#elif defined(CJSON_EXPORT_SYMBOLS)
+#define CJSON_PUBLIC(type)   __declspec(dllexport) type CJSON_STDCALL
+#elif defined(CJSON_IMPORT_SYMBOLS)
+#define CJSON_PUBLIC(type)   __declspec(dllimport) type CJSON_STDCALL
+#endif
+#else /* !__WINDOWS__ */
+#define CJSON_CDECL
+#define CJSON_STDCALL
+
+#if (defined(__GNUC__) || defined(__SUNPRO_CC) || defined (__SUNPRO_C)) && defined(CJSON_API_VISIBILITY)
+#define CJSON_PUBLIC(type)   __attribute__((visibility("default"))) type
+#else
+#define CJSON_PUBLIC(type) type
+#endif
+#endif
+
+/* project version */
+#define CJSON_VERSION_MAJOR 1
+#define CJSON_VERSION_MINOR 7
+#define CJSON_VERSION_PATCH 19
+
+#include <stddef.h>
+
+/* cJSON Types: */
+#define cJSON_Invalid (0)
+#define cJSON_False  (1 << 0)
+#define cJSON_True   (1 << 1)
+#define cJSON_NULL   (1 << 2)
+#define cJSON_Number (1 << 3)
+#define cJSON_String (1 << 4)
+#define cJSON_Array  (1 << 5)
+#define cJSON_Object (1 << 6)
+#define cJSON_Raw    (1 << 7) /* raw json */
+
+#define cJSON_IsReference 256
+#define cJSON_StringIsConst 512
+
+/* The cJSON structure: */
+typedef struct cJSON
+{
+    /* next/prev allow you to walk array/object chains. Alternatively, use GetArraySize/GetArrayItem/GetObjectItem */
+    struct cJSON *next;
+    struct cJSON *prev;
+    /* An array or object item will have a child pointer pointing to a chain of the items in the array/object. */
+    struct cJSON *child;
+
+    /* The type of the item, as above. */
+    int type;
+
+    /* The item's string, if type==cJSON_String  and type == cJSON_Raw */
+    char *valuestring;
+    /* writing to valueint is DEPRECATED, use cJSON_SetNumberValue instead */
+    int valueint;
+    /* The item's number, if type==cJSON_Number */
+    double valuedouble;
+
+    /* The item's name string, if this item is the child of, or is in the list of subitems of an object. */
+    char *string;
+} cJSON;
+
+typedef struct cJSON_Hooks
+{
+      /* malloc/free are CDECL on Windows regardless of the default calling convention of the compiler, so ensure the hooks allow passing those functions directly. */
+      void *(CJSON_CDECL *malloc_fn)(size_t sz);
+      void (CJSON_CDECL *free_fn)(void *ptr);
+} cJSON_Hooks;
+
+typedef int cJSON_bool;
+
+/* Limits how deeply nested arrays/objects can be before cJSON rejects to parse them.
+ * This is to prevent stack overflows. */
+#ifndef CJSON_NESTING_LIMIT
+#define CJSON_NESTING_LIMIT 1000
+#endif
+
+/* Limits the length of circular references can be before cJSON rejects to parse them.
+ * This is to prevent stack overflows. */
+#ifndef CJSON_CIRCULAR_LIMIT
+#define CJSON_CIRCULAR_LIMIT 10000
+#endif
+
+/* returns the version of cJSON as a string */
+CJSON_PUBLIC(const char*) cJSON_Version(void);
+
+/* Supply malloc, realloc and free functions to cJSON */
+CJSON_PUBLIC(void) cJSON_InitHooks(cJSON_Hooks* hooks);
+
+/* Memory Management: the caller is always responsible to free the results from all variants of cJSON_Parse (with cJSON_Delete) and cJSON_Print (with stdlib free, cJSON_Hooks.free_fn, or cJSON_free as appropriate). The exception is cJSON_PrintPreallocated, where the caller has full responsibility of the buffer. */
+/* Supply a block of JSON, and this returns a cJSON object you can interrogate. */
+CJSON_PUBLIC(cJSON *) cJSON_Parse(const char *value);
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLength(const char *value, size_t buffer_length);
+/* ParseWithOpts allows you to require (and check) that the JSON is null terminated, and to retrieve the pointer to the final byte parsed. */
+/* If you supply a ptr in return_parse_end and parsing fails, then return_parse_end will contain a pointer to the error so will match cJSON_GetErrorPtr(). */
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithOpts(const char *value, const char **return_parse_end, cJSON_bool require_null_terminated);
+CJSON_PUBLIC(cJSON *) cJSON_ParseWithLengthOpts(const char *value, size_t buffer_length, const char **return_parse_end, cJSON_bool require_null_terminated);
+
+/* Render a cJSON entity to text for transfer/storage. */
+CJSON_PUBLIC(char *) cJSON_Print(const cJSON *item);
+/* Render a cJSON entity to text for transfer/storage without any formatting. */
+CJSON_PUBLIC(char *) cJSON_PrintUnformatted(const cJSON *item);
+/* Render a cJSON entity to text using a buffered strategy. prebuffer is a guess at the final size. guessing well reduces reallocation. fmt=0 gives unformatted, =1 gives formatted */
+CJSON_PUBLIC(char *) cJSON_PrintBuffered(const cJSON *item, int prebuffer, cJSON_bool fmt);
+/* Render a cJSON entity to text using a buffer already allocated in memory with given length. Returns 1 on success and 0 on failure. */
+/* NOTE: cJSON is not always 100% accurate in estimating how much memory it will use, so to be safe allocate 5 bytes more than you actually need */
+CJSON_PUBLIC(cJSON_bool) cJSON_PrintPreallocated(cJSON *item, char *buffer, const int length, const cJSON_bool format);
+/* Delete a cJSON entity and all subentities. */
+CJSON_PUBLIC(void) cJSON_Delete(cJSON *item);
+
+/* Returns the number of items in an array (or object). */
+CJSON_PUBLIC(int) cJSON_GetArraySize(const cJSON *array);
+/* Retrieve item number "index" from array "array". Returns NULL if unsuccessful. */
+CJSON_PUBLIC(cJSON *) cJSON_GetArrayItem(const cJSON *array, int index);
+/* Get item "string" from object. Case insensitive. */
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItem(const cJSON * const object, const char * const string);
+CJSON_PUBLIC(cJSON *) cJSON_GetObjectItemCaseSensitive(const cJSON * const object, const char * const string);
+CJSON_PUBLIC(cJSON_bool) cJSON_HasObjectItem(const cJSON *object, const char *string);
+/* For analysing failed parses. This returns a pointer to the parse error. You'll probably need to look a few chars back to make sense of it. Defined when cJSON_Parse() returns 0. 0 when cJSON_Parse() succeeds. */
+CJSON_PUBLIC(const char *) cJSON_GetErrorPtr(void);
+
+/* Check item type and return its value */
+CJSON_PUBLIC(char *) cJSON_GetStringValue(const cJSON * const item);
+CJSON_PUBLIC(double) cJSON_GetNumberValue(const cJSON * const item);
+
+/* These functions check the type of an item */
+CJSON_PUBLIC(cJSON_bool) cJSON_IsInvalid(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsFalse(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsTrue(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsBool(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNull(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsNumber(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsString(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsArray(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsObject(const cJSON * const item);
+CJSON_PUBLIC(cJSON_bool) cJSON_IsRaw(const cJSON * const item);
+
+/* These calls create a cJSON item of the appropriate type. */
+CJSON_PUBLIC(cJSON *) cJSON_CreateNull(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateTrue(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateFalse(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateBool(cJSON_bool boolean);
+CJSON_PUBLIC(cJSON *) cJSON_CreateNumber(double num);
+CJSON_PUBLIC(cJSON *) cJSON_CreateString(const char *string);
+/* raw json */
+CJSON_PUBLIC(cJSON *) cJSON_CreateRaw(const char *raw);
+CJSON_PUBLIC(cJSON *) cJSON_CreateArray(void);
+CJSON_PUBLIC(cJSON *) cJSON_CreateObject(void);
+
+/* Create a string where valuestring references a string so
+ * it will not be freed by cJSON_Delete */
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringReference(const char *string);
+/* Create an object/array that only references it's elements so
+ * they will not be freed by cJSON_Delete */
+CJSON_PUBLIC(cJSON *) cJSON_CreateObjectReference(const cJSON *child);
+CJSON_PUBLIC(cJSON *) cJSON_CreateArrayReference(const cJSON *child);
+
+/* These utilities create an Array of count items.
+ * The parameter count cannot be greater than the number of elements in the number array, otherwise array access will be out of bounds.*/
+CJSON_PUBLIC(cJSON *) cJSON_CreateIntArray(const int *numbers, int count);
+CJSON_PUBLIC(cJSON *) cJSON_CreateFloatArray(const float *numbers, int count);
+CJSON_PUBLIC(cJSON *) cJSON_CreateDoubleArray(const double *numbers, int count);
+CJSON_PUBLIC(cJSON *) cJSON_CreateStringArray(const char *const *strings, int count);
+
+/* Append item to the specified array/object. */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToArray(cJSON *array, cJSON *item);
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObject(cJSON *object, const char *string, cJSON *item);
+/* Use this when string is definitely const (i.e. a literal, or as good as), and will definitely survive the cJSON object.
+ * WARNING: When this function was used, make sure to always check that (item->type & cJSON_StringIsConst) is zero before
+ * writing to `item->string` */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemToObjectCS(cJSON *object, const char *string, cJSON *item);
+/* Append reference to item to the specified array/object. Use this when you want to add an existing cJSON to a new cJSON, but don't want to corrupt your existing cJSON. */
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToArray(cJSON *array, cJSON *item);
+CJSON_PUBLIC(cJSON_bool) cJSON_AddItemReferenceToObject(cJSON *object, const char *string, cJSON *item);
+
+/* Remove/Detach items from Arrays/Objects. */
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemViaPointer(cJSON *parent, cJSON * const item);
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromArray(cJSON *array, int which);
+CJSON_PUBLIC(void) cJSON_DeleteItemFromArray(cJSON *array, int which);
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObject(cJSON *object, const char *string);
+CJSON_PUBLIC(cJSON *) cJSON_DetachItemFromObjectCaseSensitive(cJSON *object, const char *string);
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObject(cJSON *object, const char *string);
+CJSON_PUBLIC(void) cJSON_DeleteItemFromObjectCaseSensitive(cJSON *object, const char *string);
+
+/* Update array items. */
+CJSON_PUBLIC(cJSON_bool) cJSON_InsertItemInArray(cJSON *array, int which, cJSON *newitem); /* Shifts pre-existing items to the right. */
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemViaPointer(cJSON * const parent, cJSON * const item, cJSON * replacement);
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInArray(cJSON *array, int which, cJSON *newitem);
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObject(cJSON *object,const char *string,cJSON *newitem);
+CJSON_PUBLIC(cJSON_bool) cJSON_ReplaceItemInObjectCaseSensitive(cJSON *object,const char *string,cJSON *newitem);
+
+/* Duplicate a cJSON item */
+CJSON_PUBLIC(cJSON *) cJSON_Duplicate(const cJSON *item, cJSON_bool recurse);
+/* Duplicate will create a new, identical cJSON item to the one you pass, in new memory that will
+ * need to be released. With recurse!=0, it will duplicate any children connected to the item.
+ * The item->next and ->prev pointers are always zero on return from Duplicate. */
+/* Recursively compare two cJSON items for equality. If either a or b is NULL or invalid, they will be considered unequal.
+ * case_sensitive determines if object keys are treated case sensitive (1) or case insensitive (0) */
+CJSON_PUBLIC(cJSON_bool) cJSON_Compare(const cJSON * const a, const cJSON * const b, const cJSON_bool case_sensitive);
+
+/* Minify a strings, remove blank characters(such as ' ', '\t', '\r', '\n') from strings.
+ * The input pointer json cannot point to a read-only address area, such as a string constant,
+ * but should point to a readable and writable address area. */
+CJSON_PUBLIC(void) cJSON_Minify(char *json);
+
+/* Helper functions for creating and adding items to an object at the same time.
+ * They return the added item or NULL on failure. */
+CJSON_PUBLIC(cJSON*) cJSON_AddNullToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddTrueToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddFalseToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddBoolToObject(cJSON * const object, const char * const name, const cJSON_bool boolean);
+CJSON_PUBLIC(cJSON*) cJSON_AddNumberToObject(cJSON * const object, const char * const name, const double number);
+CJSON_PUBLIC(cJSON*) cJSON_AddStringToObject(cJSON * const object, const char * const name, const char * const string);
+CJSON_PUBLIC(cJSON*) cJSON_AddRawToObject(cJSON * const object, const char * const name, const char * const raw);
+CJSON_PUBLIC(cJSON*) cJSON_AddObjectToObject(cJSON * const object, const char * const name);
+CJSON_PUBLIC(cJSON*) cJSON_AddArrayToObject(cJSON * const object, const char * const name);
+
+/* When assigning an integer value, it needs to be propagated to valuedouble too. */
+#define cJSON_SetIntValue(object, number) ((object) ? (object)->valueint = (object)->valuedouble = (number) : (number))
+/* helper for the cJSON_SetNumberValue macro */
+CJSON_PUBLIC(double) cJSON_SetNumberHelper(cJSON *object, double number);
+#define cJSON_SetNumberValue(object, number) ((object != NULL) ? cJSON_SetNumberHelper(object, (double)number) : (number))
+/* Change the valuestring of a cJSON_String object, only takes effect when type of object is cJSON_String */
+CJSON_PUBLIC(char*) cJSON_SetValuestring(cJSON *object, const char *valuestring);
+
+/* If the object is not a boolean type this does nothing and returns cJSON_Invalid else it returns the new type*/
+#define cJSON_SetBoolValue(object, boolValue) ( \
+    (object != NULL && ((object)->type & (cJSON_False|cJSON_True))) ? \
+    (object)->type=((object)->type &(~(cJSON_False|cJSON_True)))|((boolValue)?cJSON_True:cJSON_False) : \
+    cJSON_Invalid\
+)
+
+/* Macro for iterating over an array or object */
+#define cJSON_ArrayForEach(element, array) for(element = (array != NULL) ? (array)->child : NULL; element != NULL; element = element->next)
+
+/* malloc/free objects using the malloc/free functions that have been set with cJSON_InitHooks */
+CJSON_PUBLIC(void *) cJSON_malloc(size_t size);
+CJSON_PUBLIC(void) cJSON_free(void *object);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif