A note on AI usage: agnostic is a soft work yet has many large refactors compared to upstream Lustre. This makes LLMs instrumental in managing rebases and expanding functionality. You may utilize LLMs in coding, but must understand every change. You will find even frontier models will make absolutely terrible changes without proper supervision. All issues and PRs must be written by humans
agnostic
agnostic is a soft fork of Lustre that
decouples the reconciler and runtime from the browser DOM through a composable
Platform abstraction, so the framework's ergonomics can be used for apps on
arbitrary targets.
Currently we ship a DOM platform for the browser and an OpenTUI terminal platform. While the DOM platform is usable, the intention behind including it was reusing libraries from the lustre ecosystem across platforms, which was possible due to a Gleam bug. Since that bug has now been fixed, libraries cannot be shared anymore, and it is recommended to use upstream Lustre for webapps on both client and server.
Use agnostic for the OpenTUI platform, or for building your own. A NativeScript one, for example, has been proven possible (code unavailable).
The aim is for the ideas in this repo (or parts of them) to be implemented upstream, and therefore we try to keep things up to date with it. To see how we do that, please refer to FORKING.md.
Agnostic is used in production by Bliss Writer.
Docs
Familiarity with Lustre is assumed throughout this repository. Please refer to their documentation at https://hexdocs.pm/lustre to learn more about general framework use and the browser target (client or server side).
When familiar, the differences are easily bridged by going over doc-comments and function signatures in agnostic.
Examples are also available in the examples/ folder.
agnostic/platform/opentui
Allows you to develop apps targeting OpenTUI as a renderer.
Status & Limitations
- Must use Bun as runtime
- Read the OpenTUI docs to know which attribute fit which elements
Examples
There are several examples in the examples/ folder, but usage is essentially
identical to regular Lustre, just use the elements and attributes supplied by
agnostic/platform/opentui:
gleam add agnostic
import gleam/int
import agnostic
import agnostic/effect
import agnostic/platform/opentui
import agnostic/platform/opentui/attribute
import agnostic/platform/opentui/effect as tui_effect
import agnostic/platform/opentui/element
import agnostic/platform/opentui/event
pub fn main() {
let config =
opentui.default_config()
|> opentui.use_mouse(False)
use platform <- opentui.platform(config)
let app = agnostic.application(init, update, view)
let assert Ok(_) = agnostic.start(app, on: platform, with: Nil)
Nil
}
pub type Model {
Model(count: Int)
}
pub type Msg {
Increment
Decrement
KeyPressed(tui_effect.KeyEvent)
}
fn init(_flags: Nil) -> #(Model, effect.Effect(Msg)) {
#(
Model(count: 0),
effect.batch([
tui_effect.subscribe_keyboard(KeyPressed),
tui_effect.focus("btn-plus"),
]),
)
}
fn update(model: Model, msg: Msg) -> #(Model, effect.Effect(Msg)) {
case msg {
Increment -> #(Model(count: model.count + 1), effect.none())
Decrement -> #(Model(count: model.count - 1), effect.none())
KeyPressed(key_event) ->
case key_event.key {
"tab" | "right" -> #(model, tui_effect.focus_next())
"left" -> #(model, tui_effect.focus_previous())
"up" -> #(Model(count: model.count + 1), effect.none())
"down" -> #(Model(count: model.count - 1), effect.none())
_ -> #(model, effect.none())
}
}
}
fn view(model: Model) {
element.box(
[
attribute.flex_direction("column"),
attribute.align_items("center"),
attribute.justify_content("center"),
attribute.width_("100%"),
attribute.height_("100%"),
],
[
element.box(
[
attribute.flex_direction("column"),
attribute.align_items("center"),
attribute.border_style("round"),
attribute.border_color("#444"),
attribute.padding_left(3),
attribute.padding_right(3),
attribute.padding_top(1),
attribute.padding_bottom(1),
attribute.gap(1),
attribute.title(" Counter "),
attribute.title_alignment("center"),
],
[
element.text([
attribute.content("Arrows to navigate, Enter to activate"),
attribute.bold(True),
attribute.color("#e0e0e0"),
attribute.dim(True),
]),
element.text([
attribute.content(int.to_string(model.count)),
attribute.bold(True),
attribute.color("#fff"),
]),
element.box([attribute.flex_direction("row"), attribute.gap(2)], [
element.box(
[
attribute.focusable(True),
attribute.border_style("round"),
attribute.border_color("#555"),
attribute.focused_border_color("#ff6b6b"),
attribute.focused_background_color("#2a1a1a"),
attribute.padding_left(2),
attribute.padding_right(2),
event.on_click(Decrement),
event.on_activate(Decrement),
],
[
element.text([
attribute.content(" - "),
attribute.bold(True),
attribute.color("#ff6b6b"),
]),
],
),
element.box(
[
attribute.id("btn-plus"),
attribute.focusable(True),
attribute.border_style("round"),
attribute.border_color("#555"),
attribute.focused_border_color("#69db7c"),
attribute.focused_background_color("#1a2a1a"),
attribute.padding_left(2),
attribute.padding_right(2),
event.on_click(Increment),
event.on_activate(Increment),
],
[
element.text([
attribute.content(" + "),
attribute.bold(True),
attribute.color("#69db7c"),
]),
],
),
]),
],
),
],
)
}
Further documentation can be found at https://hexdocs.pm/agnostic.
Development
gleam test --target erlang # Run the tests on Erlang
gleam test --target javascript # Run the tests on JavaScript
bun run typecheck # Typecheck the TypeScript FFI
gleam run -m build # Regenerate the server-component bundle
bun run typecheck first regenerates types/gleam.d.ts — the TypeScript
declarations for the compiled Gleam modules that the .ffi.ts files import —
and then runs tsc --noEmit. The declarations are generated from the build
output and are not committed, so a fresh clone shows unresolved imports in
.ffi.ts files until the first gleam build --target javascript && bun run typecheck.