idea: TUI for the application as an alternative entry #26
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
the app should have an additional entry (maybe like a cli tool) that starts a terminal user interface, supporting the same functionality mostly (aside from clearly graphical elements) to at least work with the blog in a structured manner, create new entries and manage publishing work flow.
Editing of text shoud preferably be also done inside the app directly, using the ExRatatui widgets to manage the text. Maybe a variation of the rich text widget can be used. Or maybe even better the normal textarea widget, but with ratatui-code-editor support, since that is most similar to what we already do in the normal GUI applicaiton.
Also images should be supported via the image widget in ExRatatui, so that we can at least get a glimpse of the media we select (since they are highly visual objects).
The terminal user interface should try to follow most UI flows from the original app. Not 100% feature parity, but preferably close to that, reusing the liveview backend code and only replacing the liveview UI parts with ExRatatui.
The original app backend code needs to be separated out into libraries probably, so that we can do a proper backend server that can then be connected from a UI client remotly, so the app could run headless on a VPS and be managed from different terminals (local and remote). This would also allow integration of terminal command shortcuts to feed data into a running instance of bds2 on a remote machine.
As part of this idea, also the GUI code should be refactored so that we have the same client/server separation as with the TUI, so that we can run headless servers and connect to them via GUI or TUI and even in parallel. This of course means that there needs to be some pub/sub pattern so that the clients get notified of central changes, so that multiple clients are synchronized in what they show.
hugo referenced this issue2026-07-14 16:30:06 +00:00
Implementation plan (agreed 2026-07-14)
Architecture: single OTP app, offline-first, two boot modes on all platforms (macOS/Windows/Linux; Linux servers first).
desktop= today, unchanged.server= headless: contexts + Repo + PubSub + LiveView endpoint bound to 127.0.0.1 +ExRatatui.SSH.Daemon(public-key auth only:authorized_keys+auto_host_keyin app-support dir,tcpip_tunnel_out: true).Single user, multiple clients (multi-location, not multi-user): all clients share the same project, settings, and workbench structure. Only the SSH port is ever exposed — TUI and GUI both go through it. No umbrella/library split, no custom wire protocol: ExRatatui-over-SSH serves the TUI (app instances run server-side, only terminal cells cross the wire), and the GUI tunnels to the localhost LiveView endpoint through the same SSH daemon with the same keys.
Phases
Phase 1 — headless boot + SSH daemon.
bds serverentry; wx-free supervision tree (extenddesktop_children/1);ExRatatui.SSH.Daemonchild; endpoint forced to localhost in server mode;check_originfor tunneled localhost. Headless works on all three OSes (no wx started at all).Phase 2 — domain event bus.
Phoenix.PubSubbroadcasts from contexts (Posts,Media,Publishing,Tags,Templates,Settings); all clients subscribe. Enables: multi-client sync, external pipeline ingestion updating the GUI live (incl. existingCliSync.Watcherpath), and the server-side UI-language rule (language is a server setting; changing it broadcasts and every client re-renders). Newspecs/events.allium.Phase 3 — UI core extraction. Move remaining
shell_live/*logic intoBDS.UI.*pure command→state+effects functions; LiveView and TUI become thin renderers over one shared core and one shared workbench session. Per-editor, test-first.Phase 4 — TUI app.
ExRatatui.Appover Workbench/Sidebar/Dashboard: List/Table sidebar, Tabs strip,Textareapost editing (plain text in v1, see follow-ups),Markdownhighlighted preview pane,Widgets.Imagefor media glimpses, status line + Throbber as toast equivalent. AI quick actions (auto-translate, alt-text, one-shots) included and airplane-mode gated exactly like the GUI; no chat panel. Localbds tuiand remotesshhit the same app module. Tests drive the TUI viaExRatatui.CellSession; newspecs/tui.allium, weeded against code.Phase 5 — GUI remote mode. Desktop app "Connect to server…":
:ssh.connectwith user key →:ssh.tcpip_tunnel_to_server→ webview at the local tunnel port. Native menu entry via macOS intercept, localized. Local desktop mode stays the offline-first default. Plain browsers work too via standardssh -L.Phase 6 — gates. gettext for all new strings (de/fr/it/es, real translations, both localization areas), DOCUMENTATION.md server-setup + key-management section, clean build/test/credo/deps.audit/dialyzer. No new metadata fields.
Syntax-highlighted editing (deferred from v1)
ExRatatui 0.11 has no ratatui-code-editor integration:
Textareawrapsratatui-textarea(no highlighting),CodeBlockis display-only, and custom widgets are pure-Elixir compositions — external Rust cannot draw into the NIF's buffer without forking it. Since only markdown highlighting is needed, the preferred follow-up is a pure-ElixirMarkdownEditorcustom widget (own line buffer/cursor/undo, per-line styledText.Spans from an mdex-based tokenizer — no fork, no Rust,CellSession-testable). An upstream PR wrapping ratatui-code-editor remains the long-term option if the Elixir widget hits a ceiling.Follow-ups (separate issues)
Phase 1 finished (commit
700ed79onfeature/issue-26-tui-headless-server)Headless boot mode + SSH daemon:
BDS_MODE=serverboots the app with no wx children at all (works headless on macOS/Windows/Linux): loopback HTTP endpoint + CLI sync watcher +ExRatatui.SSH.Daemon.BDS.TUI(placeholder shell for now; the real workbench renderer lands in phase 4) — every SSH client gets its own app instance on the server.auth_methods: publickey, no password callback). Host key is generated on first boot;authorized_keysis created empty with mode 600. All key material lives in<data dir>/sshnext to the database — never in the repo or priv.tcpip_tunnel_outis enabled so GUI clients can tunnel to the loopback endpoint through the same SSH connection (phase 5).BDS_SSH_PORTenv orconfig :bds, :server, ssh_port:(default 2222).specs/server.allium(validates clean), 11 new tests (mode resolution, key-material handling, daemon opts, mode children), new TUI strings translated for de/fr/it/es.BDS.Scripting.JobTest(1s → 5s cleanup timeout).Gates: build (warnings-as-errors) ✅, full suite 1314 tests / 0 failures after flake fix ✅, credo --strict ✅, deps.audit ✅, dialyzer ✅.
Next: phase 2 — domain event bus on
BDS.PubSub.Phase 2 finished (commit
16f45d7onfeature/issue-26-tui-headless-server)Domain event bus:
BDS.Events: contexts broadcast{:entity_changed, %{entity, entity_id, action}}on the same"entity:changed"topic the CLI sync watcher already uses, so one subscription covers in-app mutations, other clients, and external CLI/pipeline writes. Global settings writes broadcast{:settings_changed, key}on"settings:changed".Posts(create/update/publish/delete),Media,Tags(incl. rename/merge),Templates,Scripts, andSettings.put_global_setting.ShellLivenow subscribes viaBDS.Events.subscribe()— two GUI windows (and later TUI sessions) stay synchronized; a pipeline ingesting content outside the GUI updates every open shell live.ui.language): changing it in any client persists it and re-localizes every connected client via the broadcast. OS locale (LC_ALL/LANG) remains the fallback for a fresh install.ShellData.status_bar/4usesKeyword.get_lazy(its eagerly-evaluated default was re-reading the setting on every layout event) andeditor_meta/2takes the caller's already-resolved locale.specs/events.allium(validates clean); 7 new tests covering broadcasts across all contexts and the ui.language override.Gates: build (warnings-as-errors) ✅, full suite 0 failures ✅, credo --strict ✅, deps.audit ✅, dialyzer ✅.
Next: phase 3 — extract remaining pure post-editor logic into
BDS.UI.PostEditor.*(the socket-facing half ofDraftManagementturned out to be dead code and gets deleted).Phase 3 finished (commit
3a8fafdonfeature/issue-26-tui-headless-server)UI core extraction:
BDS.UI.Workbench,Sidebar,Dashboard,Commands,Sessionhave zero LiveView imports), so the real gap was the post editor.BDS.UI.PostEditor.*:Persistence(save/publish/discard workflow incl. canonical-vs-translation routing),Metadata(project metadata, translations, template options, linked media, post links, footer),ListValues(tag/category chips + suggestions), and newDraft(persisted form building, param normalization, language handling).BDS.Desktop.ShellLive.PostEditornow imports fromBDS.UI.PostEditor.*; the TUI (phase 4) drives the exact same save/publish/discard code paths without touching the Desktop namespace.DraftManagement's socket-facing half entirely — it turned out to be dead code (defined, never called; the LiveComponent had its own equivalents). Net −66 lines.Gates: build (warnings-as-errors) ✅, full suite 1325 tests / 0 failures ✅, credo --strict ✅, deps.audit ✅, dialyzer ✅.
Next: phase 4 — the TUI itself (
ExRatatui.Appover the shared core).Phase 4 finished (commit
a3ca446onfeature/issue-26-tui-headless-server)Terminal UI:
BDS.TUIis now a realExRatatui.Appover the shared core: sidebar fromBDS.UI.Sidebar(same data as the GUI, grouped sections with header-skipping navigation), view tabs (posts/media/templates/scripts/tags via1-5), and a status line with contextual key hints.enteropens a post with title +Textareabody seeded fromBDS.UI.PostEditor.Draft.persisted_form;ctrl+ssaves andctrl+ppublishes throughBDS.UI.PostEditor.Persistence— the exact code paths the GUI uses (files, embeddings, search, auto-translation, event bus all behave identically).ctrl+tedits the title,ctrl+lcycles languages (canonical vs translations),ncreates a draft.enteron an image renders it in the terminal viaExRatatui.Widgets.Image(kitty/sixel/halfblocks auto-negotiated),esccloses.ctrl+gruns the one-shotAI.analyze_post(title/excerpt suggestions applied to the post), airplane-mode gated — without a local endpoint it shows a status message instead of calling out.BDS.Events— sidebar refreshes on any entity change from any client or pipeline; a post deleted elsewhere closes the open editor;ui.languagechanges re-localize the session (server-side locale, shared by all clients).BDS_MODE=tui: the headless server plus a TUI on the launching terminal (SSH daemon still runs, so GUI/TUI clients can attach in parallel).ExRatatui.CellSession(render-to-cells assertions + real context persistence); newspecs/tui.allium(validates clean); all 18 new UI strings translated for de/fr/it/es, plus two pre-existing fuzzy entries fixed ("Media Files", "Delete Tag").Skipped for v1 (as planned): syntax-highlighted editing (follow-up issue — plain textarea now), Throbber widget (text "Working…" status covers it), chat panel.
Gates: build (warnings-as-errors) ✅, full suite 1336 tests / 0 failures ✅, credo --strict ✅, deps.audit ✅, dialyzer ✅.
Next: phase 5 — desktop "Connect to Server…" over the same SSH channel.
Phase 5 finished (commit
df2bdc2onfeature/issue-26-tui-headless-server)GUI remote mode over the same SSH channel:
BDS.Desktop.RemoteConnection: parsesuser@host[:port], connects with OTP:sshusing public-key auth only (client identity + trust-on-first-useknown_hostsin the same privatessh/dir the server uses), then opens an OTP TCP/IP tunnel (:ssh.tcpip_tunnel_to_server/6) to the server's loopback HTTP endpoint. The webview loads the local tunnel end — the server's HTTP port is never exposed. Connection loss is monitored and reported.BDS.Desktop.MenuBar) and through the shell dispatcher for the non-macOS titlebar menu; disconnect returns the window to the local workspace.check_originnow accepts any-port loopback origins because the tunnel terminates on an ephemeral local port.BDS.Desktop.Dialogs(native osascript text prompt + alert, same pattern as the file picker).specs/server.alliumgained theGuiRemoteConnectionrule; all 6 new UI strings translated for de/fr/it/es; README remote-GUI section updated.Gates: build (warnings-as-errors) ✅, full suite 0 failures ✅, credo --strict ✅, deps.audit ✅, dialyzer ✅.
Next: phase 6 — final gates + end-user documentation (server setup and key management in DOCUMENTATION.md).
Phase 6 finished (commit
48e0903onfeature/issue-26-tui-headless-server) — issue #26 implementation complete.Docs + final gates:
BDS_MODE=server, key management (authorized_keysin the privatessh/dir), connecting viassh(TUI), File → Connect to Server… (GUI), browser-over-tunnel, and the multi-client sync / shared-language behavior.app.get_system_languagedescription updated for the new semantics (server-side UI language setting, OS locale fallback) and regenerated from the live contract (docs sync tests green).Summary of the whole implementation (phases 1–6):
BDS_MODE=server) +ExRatatui.SSH.Daemon, public-key-only auth, key material in the private data dir,tcpip_tunnel_outfor GUI clients.BDS.Events) — all contexts broadcast; all clients synchronized; UI language became a server-side setting.BDS.UI.PostEditor.*; dead socket-half ofDraftManagementdeleted.BDS.TUI) over the shared core: posts/media/templates/scripts/tags, post editing + publish, image preview, airplane-gated AI quick action,BDS_MODE=tuilocal mode, CellSession-driven tests.Deferred to follow-ups (as agreed): markdown-syntax-highlighted editor widget (pure-Elixir custom widget preferred; upstream ratatui-code-editor PR as the long-term option), terminal command shortcuts (covered by its own issue).
Branch
feature/issue-26-tui-headless-serveris ready for review/merge.Post-phase fix (commit
c776862): quitting the localBDS_MODE=tuiTUI (ctrl+q) now stops the whole VM instead of leaving the headless server running in the foreground. The local TUI child getsstop_vm_on_exit: trueandBDS.TUI.terminate/2callsSystem.stop()— SSH-served sessions never get the flag, so remote quits stay session-local. Covered by 3 new/updated tests; full suite 1347/0, all gates green.Post-phase addition (commit
01b90e0): word-wrapped preview in the TUI post editor. The editing textarea cannot soft-wrap — upstreamratatui-textarealimitation, no wrap option in the crate or the ExRatatui NIF surface — soctrl+enow toggles a read-only, word-wrapped Markdown rendering of the current draft (escorctrl+ereturns to editing; keys in preview never touch the content). Proper soft-wrap inside the editing surface is folded into the planned MarkdownEditor custom-widget follow-up as a hard requirement alongside syntax highlighting. 3 new CellSession tests (wrap visibility, edit protection, esc behavior); spec + README + all four locales updated. Full suite 1350/0, credo/deps.audit/dialyzer clean.Post-phase addition (commit
824313f): vi-style command prompt in the TUI.:opens a filterable list of the parameterless Blog-menu shell commands — metadata diff, validate site, force render site, generate site, rebuild database, reindex text, rebuild embedding index, regenerate calendar, validate/fill translations, find duplicates, upload site, open-in-browser (URL shown) — and runs the selection through the sameBDS.Desktop.ShellCommandsbackend the GUI menu uses.?(or:?) shows the same list as help. Commands whose report/apply follow-up UI exists only in the GUI (metadata diff, validate site, validate translations, find duplicates) are marked[report/apply in GUI]. Queued tasks stream progress into the status line until all tasks finish, then the sidebar refreshes. The parameterized follow-ups (repair_metadata_diff,import_metadata_diff_orphans,apply_site_validation) need report selections and stay GUI-only until a TUI report pane lands (natural next increment). 6 new tests; spec/README/all four locales updated. Full suite 1356/0, credo/deps.audit/dialyzer clean.Post-phase addition (commit
39bb232): TUI report panels + palette polish.metadata_diff/validate_sitetasks open a scrollable panel (↑/↓) showing the differences — per-entity field diffs (db=… → file=…) plus orphan files, and missing/extra/updated path sections plus sitemap status for validation.enterapplies the whole report: metadata diff repairs all items from the filesystem (file → db) and imports all orphans; site validation applies the full report incrementally, same as the GUI.esccancels without applying. Reports completed before the session started never pop up (pre-existing completed tasks are marked handled at mount).Clearwidget underneath — no more background text shining through.?in the sidebar is gone; help is:?from inside the prompt, vscode-palette-style type-to-filter was already in place (matches command ids and localized labels).[report/apply in GUI]markers were dropped; onlyvalidate_translationsandfind_duplicateskeep it.:?help); spec/README/all four locales updated. Full suite 1362/0, credo/deps.audit/dialyzer clean.Post-phase fix (commit
a8ca3b6): typing a filter that matches no command (e.g.:j) crashed the TUI render on every frame — ExRatatui'sListwidget raises whenselectedis set on an empty collection, which painted errors across the screen. All three dynamic lists (command palette, sidebar, report panel) now clamp through a sharedlist_selected/2helper that returnsnilfor empty collections and clamps out-of-range indexes; the sidebar had the same latent bug for empty views. Reproduced by a new regression test before fixing. Full suite 1363/0, credo/dialyzer clean.