Metadata-Version: 2.4
Name: tyrtulia-tui
Version: 1.9.0
Summary: Terminal client for Tyrtulia, the headless forum for small multi-user servers
Keywords: forum,bbs,tilde,pubnix,tui,textual
Author: Andros Fenollosa
License-Expression: GPL-3.0-or-later
License-File: LICENSE
Requires-Dist: httpx>=0.28
Requires-Dist: textual>=8.2
Requires-Python: >=3.13
Project-URL: Homepage, https://git.andros.dev/andros/tyrtulia-tui
Project-URL: Repository, https://git.andros.dev/andros/tyrtulia-tui
Description-Content-Type: text/markdown

# Tyrtulia TUI

Terminal client for [Tyrtulia](https://git.andros.dev/andros/tyrtulia), the headless forum for small multi-user servers (tildes, pubnixes, club servers). Built with [Textual](https://textual.textualize.io).

The server, its API and how to run an instance are in the [Tyrtulia repository](https://git.andros.dev/andros/tyrtulia) (also on PyPI as [`tyrtulia`](https://pypi.org/project/tyrtulia/)).

- On the server itself it talks to Tyrtulia through its Unix socket: the operating system tells the forum who you are, so there is nothing to log in to.
- From anywhere else it logs in with your SSH key (a signed challenge) and keeps the session token in a private file. Before signing, it checks that the login message names the server it is talking to, so a malicious server cannot reuse your signature on another instance.
- It only knows `GET /` and follows the links the forum returns: an action appears in the footer only when the forum offers it to you (reply, edit, pin, hide...). It never builds, reads or changes a URL of the API: page sizes and positions are variables of the forum's templated links, and orders, views and filters are links of their own.
- Notifications arrive in real time over Server-Sent Events.
- It reads several forums at once: their threads and notifications come together in one list, and search results side by side. You write only where you are registered (where your SSH key is, or through the socket); elsewhere you read as a guest.
- It browses the [categories](#categories) of each forum, with their subcategories, and moderators manage them from the keyboard.
- It speaks English and Spanish, and [more languages](#languages) can be added with a `.po` file.

![A thread in Tyrtulia TUI](https://git.andros.dev/andros/tyrtulia-tui/raw/branch/main/docs/thread.png)

One forum:

![The list of threads of one forum](https://git.andros.dev/andros/tyrtulia-tui/raw/branch/main/docs/threads.png)

Three forums at once, the last one read as a guest:

![The threads of three forums in one list](https://git.andros.dev/andros/tyrtulia-tui/raw/branch/main/docs/forums.png)

## Requirements

- Python 3.13 or later.
- OpenSSH client (`ssh-keygen`), to sign the login challenge when you connect from outside the server.
- A [Tyrtulia](https://git.andros.dev/andros/tyrtulia) instance, 1.8 or later for everything. Older forums still work, with less: before 1.8 a category cannot show only its own threads; before 1.7 there are no categories (new threads go to the forum itself, as always); before 1.5, no tree or levels view (threads show in order), no order by activity, no unread-only notifications and no search in titles. The client never rebuilds those by hand. 1.2 adds login messages bound to the instance. To set one up, follow its [getting started guide for administrators](https://git.andros.dev/andros/tyrtulia#getting-started-for-administrators).

## Installation

With [uv](https://docs.astral.sh/uv/):

```sh
uv tool install tyrtulia-tui
```

With pip:

```sh
python3.13 -m venv ~/.local/share/tyrtulia-tui
~/.local/share/tyrtulia-tui/bin/pip install tyrtulia-tui
ln -s ~/.local/share/tyrtulia-tui/bin/tyrtulia-tui ~/.local/bin/tyrtulia-tui
```

Administrators can install it once for every user of the server, for example in `/opt/tyrtulia-tui` with a link in `/usr/local/bin`.

## Usage

On the server, just run it. It finds the socket in `/run/tyrtulia/tyrtulia.sock` (or `/var/run/tyrtulia/tyrtulia.sock` on BSD):

```sh
tyrtulia-tui
```

From another machine, point it to the forum and give your username and SSH key. Your first SSH key is registered from the server itself (profile, `a`) or by the operator:

```sh
tyrtulia-tui --url https://forum.example.org --user alice --key ~/.ssh/id_ed25519
```

The key can also be the `.pub` file, to sign with `ssh-agent`. Without `--user`, you read as a guest and can log in later with `l`.

### Several forums

Repeat `--url` (or `--socket`) to read several forums together:

```sh
tyrtulia-tui --url https://forum.example.org --url https://tilde.example --user alice
```

- The list of threads mixes every forum into one, with a column that says where each one comes from: pinned threads first, then the newest by creation (the default) or by the last post (`s`). Each forum keeps its own order, and the next thread is always the newest of what every forum has left, asking a forum for its next page only when it runs out; so the order holds across pages, even when one forum is much busier than another. Forums older than 1.5 do not order by activity and are left out of that order, with a notice. Notifications are mixed the same way, newest first.
- Search results are interleaved: the best match of each forum, then the second of each, and so on, since each forum scores against its own posts and the scores do not compare. The screen says so.
- At start it logs in to every forum with the same username and key. Where your key is not registered, the login fails and you read that forum as a guest: no reply, answer or new thread there. `l` logs in to one of them later.
- When an action needs one forum (`n` new thread, `c` categories, `p` profile, `@` user, `l` log in, `o` log out) and more than one fits, it asks which. `n` only offers the forums where you can write, and then asks for the category in that forum.
- Categories belong to one forum: the list of categories and the threads of a category show one forum at a time. The mixed list of every forum labels each thread with its category.
- A forum that cannot be reached at start is left out, with a message.

| Option | Meaning |
|---|---|
| `--socket PATH` | Unix socket of a server. Repeatable. |
| `--url URL` | URL of a server, for remote access. Repeatable. |
| `--user NAME` | Username for the SSH key login. |
| `--key FILE` | SSH private key (or its `.pub` with the agent). Defaults to `~/.ssh/id_ed25519`, `id_ecdsa` or `id_rsa`. |
| `--editor COMMAND` | Your editor for `Ctrl+O` (see [Writing in your own editor](#writing-in-your-own-editor)). Defaults to `$VISUAL`, `$EDITOR` or `vi`. |
| `--language CODE` | Language of the interface, such as `es` (see [Languages](#languages)). Defaults to your environment. |
| `--config FILE` | Configuration file. |

### Configuration

Every key is optional; command line options win:

```toml
# ~/.config/tyrtulia-tui/config.toml
url = "https://forum.example.org"   # your forum from outside; on its server the socket is used instead
username = "alice"
key = "~/.ssh/id_ed25519"
editor = "nvim"            # for Ctrl+O; also "emacs -nw", "nano", "code --wait"...
language = "es"            # the interface in Spanish, whatever the environment says
# socket = "/run/tyrtulia/tyrtulia.sock"
```

For several forums, add a `[[forum]]` table for each one. The top-level `username` and `key` apply to every forum that does not set its own:

```toml
username = "alice"
key = "~/.ssh/id_ed25519"

[[forum]]
url = "https://forum.example.org"

[[forum]]
url = "https://tilde.example"
username = "alice_t"       # another name on this one

[[forum]]
socket = "/run/tyrtulia/tyrtulia.sock"
```

Without `--url` or `--socket` on the command line, it opens the main forum and every `[[forum]]` table. The top-level `socket` and `url` describe the main forum, one forum: through its socket when there is one (the configured `socket`, or the local socket of the server you are on), otherwise through its `url`. So the same configuration works on the server and from your laptop, and on the server you do not get the same forum twice. `--url` and `--socket` replace them all.

On the server, a top-level `url` is ignored even if it points to another forum: the local socket takes its place. Put any other forum in a `[[forum]]` table:

```toml
key = "~/.ssh/id_ed25519"

[[forum]]
url = "https://other-tilde.example"
username = "alice"      # your name there; you write if your key is registered
```

On the server this opens its own forum through the socket plus `other-tilde.example`; from your laptop, only `other-tilde.example`.

Session tokens are kept in `~/.local/state/tyrtulia-tui/tokens.json` (mode `0600`), and the last post you read in each thread in `read.json` next to it, to show how many posts are new. Both follow `XDG_STATE_HOME`.

## Keys

| Where | Key | Action |
|---|---|---|
| Everywhere | `q` | Quit |
| | `i` | Notifications |
| | `/` | Search (`words`, `"a phrase"`, `OR`, `NOT`, `prefix*`) |
| | `p` | Your profile and SSH keys |
| | `@` | Open a user by name |
| | `l` / `o` | Log in / log out (remote access) |
| Lists | `↑` `↓` (`k` `j`) | Move up and down (threads, notifications, search results, SSH keys) |
| Threads | `Enter` | Open |
| | `n` | New thread (asks in which forum, if you can write in several, and then in which category) |
| | `c` | [Categories](#categories) (asks in which forum, if there are several) |
| | `w` | Latest or watching |
| | `s` | Order: by creation (default) or by the last post |
| | `r` | Refresh |
| | `]` / `[` | Next / previous page |
| | `L` | Moderation log (public): `Enter` opens what an entry points to |
| Categories | `Enter` | Open a category: its threads |
| | `u` | From a list of subcategories, up to their category |
| | `A`, `E`, `D` | Moderators: add a category (or a subcategory, in a list of subcategories), edit the selected one (name, description, position), delete it (only when empty, after confirming) |
| A category | `Enter`, `n`, `s`, `r`, `]` / `[` | As in the list of threads; `n` starts the thread in this category |
| | `c` | Its subcategories |
| | `t` | Only its own threads, or again with those of its subcategories (when it has subcategories) |
| | `u` | Up: to its parent category, or to the list of categories |
| | `/` | Search in this category and its subcategories |
| | `A`, `E`, `D` | Moderators: add a subcategory, edit this category, delete it |
| Choosing a category | `Enter` | Choose the highlighted category |
| | `→` / `←` (`l` / `h`) | Go into its subcategories / back to the top |
| Thread | `↑` `↓` (`k` `j`) | Move between posts (the selected one has an orange bar) |
| | `u` | Go to the post the selected one answers; in the levels view, from the post on top, go up one level |
| | `w` | Open the profile of the selected post's author |
| | `v` | [View](#thread-views): tree, levels or linear |
| | `Enter` | Levels view: open the selected post with its answers (or click `▸ N replies`) |
| | `r` | Reply to the thread |
| | `a` | Answer the selected post |
| | `e` / `d` | Edit / delete the selected post |
| | `D` | Delete the whole thread, after confirming (its author while nobody else has answered, or a moderator) |
| | `s` | Subscription: watching, normal, muted |
| | `t` | Change the title |
| | `c` | Open the thread's category |
| | `M` | Move the thread to another category (its author or a moderator) |
| | `f` | Report the selected post |
| | `h`, `P`, `C` | Moderators: hide or unhide, pin, close. With categories, `P` asks whether to pin in every list or only in the thread's category |
| | `Esc` | Back |
| Editor | `Ctrl+S` | Send |
| | `Ctrl+O` | Continue in your own editor |
| | `F7` | Select all |
| | `Esc` | Cancel |
| Notifications | `Enter` | Open what it points to: the post, or for reports the post, thread or user |
| | `a` | Mark all as read |
| | `u` | Only unread, or all |
| Search | `Ctrl+T` | Only in titles, or in titles and posts |
| | `/` | From the results, back to the query to search again |
| Profile | `e` | Edit your about |
| | `a` / `x` | Add an SSH key from a `.pub` file / delete the selected key |
| User | `f` | Report the user |
| | `B`, `R` | Moderators: ban or unban, make read-only or remove it (a reason is asked and is public in the moderation log) |
| | `Esc` | Back |

Only the actions the forum allows you appear in the footer.

### Categories

Since Tyrtulia 1.7 every thread belongs to a category, and a category can have subcategories (one level).

- `c` opens the categories of a forum: name, number of threads, subcategories, last activity and description. `Enter` shows the threads of a category, its own and those of its subcategories, pinned ones first, by creation or by the last post (`s`), page by page; `t` leaves only its own. From there `c` lists its subcategories, `u` goes back up and `/` searches only in that category.
- Moderators can still open a deleted category (from the moderation log, for instance): it says when it was deleted. For everyone else, entries of the log and notifications that point to something deleted say it is no longer available.
- `n` asks for the category of the new thread: the top-level categories first, `→` to go into the subcategories of one. You can choose a category or a subcategory; only those where you can write are offered.
- Every list of threads has a Category column (`Parent/Child`), and the thread shows its category in the title. `c` in a thread opens its category; `M` moves the thread to another one.
- The first column marks `P` for a thread pinned in every list, `p` for one pinned only in its category and `C` for a closed one.
- Moderators add, edit and delete categories with `A`, `E` and `D`. The form checks the forum's rules (lengths, positions) before sending, and deleting asks first; the forum only deletes empty categories.

### Thread views

`v` cycles through three ways of reading a thread; the title shows the current one.

- **Tree**: the whole conversation, each answer indented under the post it answers. The forum nests up to eight levels (deeper answers stay on the eighth) and sends up to 500 answers per post; when a post has more, it shows `▸ N more replies`, and `Enter` (or a click) opens it in the levels view with all of them.
- **Levels**: one level at a time, for long threads such as wiki pages. It starts with the posts that answer no other post. `Enter` (or a click on `▸ N replies`) opens a post with its direct answers, and so on; `u` on the post on top goes back up. The title shows where you are, such as `levels: #2 > #3`. Only the level on screen is fetched, so it works on threads of any size.
- **Linear**: every post in the order it was written.

Tree and levels need Tyrtulia 1.5: on older forums the thread shows in order, and the title says why. A thread shows up to 500 posts; `m` asks for more.

### Writing in your own editor

You can write posts in your usual terminal editor instead of the built-in one:

1. Open the editor of the TUI as usual: `n` for a new thread, `r` to reply, `a` to answer a post or `e` to edit one. For a new thread, type the title first.
2. Press `Ctrl+O`. The TUI steps aside and your editor opens with the draft.
3. Write, save and quit (`:wq` in vim, `C-x C-s` and `C-x C-c` in Emacs, `C-x C-s` and `C-x #` with `emacsclient`, `Ctrl+O` and `Ctrl+X` in nano).
4. You are back in the TUI with the text. Check it and press `Ctrl+S` to send, or `Ctrl+O` again to keep editing.

The editor is, in this order: `--editor` on the command line, `editor` in the configuration, `$VISUAL`, `$EDITOR` and finally `vi`. Arguments are allowed:

```toml
# ~/.config/tyrtulia-tui/config.toml
editor = "nvim"
# editor = "emacs -nw"
# editor = "emacsclient"     # already inside Emacs (vterm, eat, shell): finish with C-x #
# editor = "nano"
# editor = "hx"
# editor = "code --wait"     # graphical editors need their "wait" option
```

```sh
tyrtulia-tui --editor "emacs -nw"
```

- The draft is a temporary `.md` file only you can read, removed when you come back.
- If the editor exits with an error or cannot be found, the text in the TUI stays as it was and the reason is shown.
- The rules of the forum (lengths, too many mentions...) are checked when you send, as with the built-in editor; if the forum rejects the post, the editor opens again with your text.
- Notifications keep arriving while you write; you see them when you come back.

## Languages

The interface is in English and Spanish (`es`). It takes the language from, in this order:

1. `--language` on the command line, such as `--language es`.
2. `language` in the configuration file.
3. The environment, as every gettext program: `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG` (`LANG=es_ES.UTF-8` gives Spanish).

Without a translation for that language, it uses English; when the language was chosen with `--language` or `language`, it says so and lists the available ones.

What the forum's people write is never translated: titles, posts, names of categories, reasons of moderators, profiles. Everything of the client is: footer keys, titles, columns, dialogs, notices, the rules of the forms and the messages for the forum's errors (from the type of each error, not from its English text). Dates stay as `2026-11-06 18:30` in every language.

### Adding a language

Translations are gettext catalogs. The English texts of the code are collected in `src/tyrtulia_tui/locale/tyrtulia_tui.pot`, and each language has its own `.po` file, the one translators edit, and its compiled `.mo`, the one the program reads. Both go in the repository and in the published package. [Babel](https://babel.pocoo.org) does the work through `scripts/i18n.sh`:

1. Create the catalog of your language (French, `fr`, in this example):

   ```sh
   docker compose run --rm dev scripts/i18n.sh init fr
   ```

2. Translate every `msgstr` of `src/tyrtulia_tui/locale/fr/LC_MESSAGES/tyrtulia_tui.po` with any text editor or a `.po` editor such as [Poedit](https://poedit.net). Keep the placeholders in braces (`{count}`, `{forum}`...) as they are, and give every plural form. Do not leave a `msgstr` empty or marked `fuzzy`.
3. Compile it and run the checks:

   ```sh
   docker compose run --rm dev sh -c 'scripts/i18n.sh compile && pytest tests/test_i18n.py'
   ```

4. Try it with `--language fr` and send the `.po` and `.mo` files in a pull request.

When the code changes its texts, `scripts/i18n.sh update` brings the template and every `.po` up to date: translate the new entries and compile again. `tests/test_i18n.py` fails if a text of the code is missing in the template or in a catalog, if a translation is empty or `fuzzy`, if a placeholder changes, or if a `.mo` does not match its `.po`. Without Docker, `uv run scripts/i18n.sh ...` works the same.

## Development

Everything runs in Docker:

```sh
docker compose run --rm dev sh -c 'ruff format src tests && ruff check src tests && mypy && pytest'
```

The tests drive the client and the interface (Textual's `Pilot`) against a real Tyrtulia instance running in-process, plus a real `tyrtulia serve` with Unix users inside the container. The server is the one locked from PyPI (1.6 for now, which exercises the forum without categories; tests that need 1.7 or 1.8 are skipped). Run the suite again with the newest server installed over it, from PyPI or from a wheel of an unreleased one:

```sh
docker compose run --rm -v /path/to/wheels:/wheels dev sh -c 'uv pip install --python /opt/venv/bin/python /wheels/tyrtulia-*.whl && pytest'
```

The suite runs in English whatever your environment says; `tests/test_i18n.py` covers the translations, with the interface in Spanish too.

`tests/test_no_paths.py` fails if the client writes a path of the API or a query parameter by hand: the contract is the link relations and the representations, so URLs are never built, read or changed.

`e2e/run.sh` runs the end-to-end test: on Debian with systemd, ten people use the real TUI in `tmux` against one Tyrtulia 1.8 instance installed from PyPI (to try an unreleased server, pass its wheel: `TYRTULIA_WHEEL=/path/to/tyrtulia-x.y.z-py3-none-any.whl e2e/run.sh`). Five Unix users go through the socket and five SSH key accounts log in over HTTP; they post, answer, mention, edit, moderate, report and search, organise categories, move and pin threads, and see each other's notifications in real time.

## Contributing

Contributions are welcome! Please see the [contribution guidelines](https://git.andros.dev/andros/contribute) for instructions on how to submit issues or pull requests. Changes to the forum itself (the API, the server) belong in the [Tyrtulia repository](https://git.andros.dev/andros/tyrtulia).

## License

Tyrtulia TUI is free software, released under the [GNU General Public License v3.0 or later](LICENSE).
