iTUI
A configurable terminal UI for Linux, built with ATUI.
iTUI turns plain text files into a working terminal application: a structured menu that runs system commands and applications, and simple forms that collect the parameters those commands need.
Features
- Structured menus — nested entries that call system commands and
applications, described in
data/menus/main.json. - Simple forms — a menu entry can ask for a command's arguments before it
runs, and fill its
{{placeholders}}with the answers. - Declarative schemas — forms and data structures are defined in
data/schemas/*.json, not in code. - Data layer — Ecto changesets over the JSON-declared schemas, and one repository interface with the records kept in readable JSON files.
- A todo list — the forms and the data layer with a screen in front of them.
Status
Early development.
- Menu MVP — done:
df,uptimeandfreerun from the menu. - Forms & data MVP — done: schemas in
data/schemas/, a JSON-backed repository, forms over both, and a todo list built out of them. - Ecto — done: casting and validation are
Ecto.Changesets built from the schema files.
Installation
Requires Elixir ~> 1.19 and OTP 28.
mix escript.install hex i_tui
That puts itui in ~/.mix/escripts; add it to your PATH if it is not
there already. From a clone instead:
git clone https://github.com/iboard/itui.git
cd itui
mix deps.get
mix escript.build && ./itui
Running it
itui # open the menu
itui --where # say which data directory it is using
itui --version
itui --help # all of it, at length
The escript carries +Bc, which is what makes Ctrl-C reach the application
instead of opening the BEAM's BREAK menu. From a clone, bin/itui does the
same for mix run.
Going straight there
Some of what iTUI does wants a screen, and some of it is a sentence. Both are the same command, because they are the same application: the menu, the schema and the records a line reads are the ones the screen shows.
itui menu system/uptime # open the menu there, and run it
itui todo # open the todo list
A path says its way down the menu, as system/uptime or as the keys the
entries carry, s/u — or as words, itui menu system uptime. A space may be
written as a dash, and as much of an entry's name as says which one it is will
do: itui menu system/disk. What happens at the end of the path is what
pressing enter there would do, so an entry that asks for its arguments still
asks. A name that is not in the menu is a line on the terminal and an exit
status of 1, rather than a popup over a screen you did not want.
The todo list without a screen
itui todo add "Buy milk" --due tomorrow --priority 1
itui todo done 3
itui todo list --hide done
The options itui todo add takes are the fields of your todo schema, read
from the file: --title, --description, --url, --priority, --due, and
--done on its own for a yes/no field. The words left over are what the todo
is called, so itui todo add "Buy milk" needs no --title. Values are cast
by the schema, which means --due tomorrow and the same complaint about a day
that is not one as the form gives.
itui todo done takes the numbers in the # column, one or several, and
writes the moment the tick went in the way the screen does. Every number is
looked up before any of them is written, so a typo at the end of the line does
not leave half the work done and unsaid.
itui todo list prints the columns the schema names and stops. --only and
--hide name the kinds of todo to show and to leave out — the same kinds f
hides and shows on the screen — as a list separated by commas or as the option
again:
done · overdue · soon · week · month · later · none
$ itui todo list --hide done
# Done P Created Due Checked Title Description
─ ──── ─ ────────── ────────── ─────── ────────────── ─────────────────────
1 [ ] 1 2026-09-19 2026-09-20 Buy milk
3 [ ] 2 2026-09-19 2026-09-01 Publish it to Hex, once it is …
Where your files live
The menus and schemas are yours to edit, so they live in your home rather than next to the code. iTUI carries the ones it ships with and writes them out the first time it runs, into the first of:
$ITUI_DATA |
a directory said outright |
~/.itui |
if that is where you keep it |
~/.config/itui |
the default (or $XDG_CONFIG_HOME/itui) |
~/.config/itui/menus/main.json the menu
~/.config/itui/schemas/*.json forms and data structures
~/.config/itui/records/*.json the records themselves
A file that is already there is never written over, so an upgrade that adds a
schema adds it and your edited menu stays edited. itui --where says which
directory is in use.
Keys
↑ ↓, k j |
move through the entries |
enter, → |
open a submenu, or run the command |
| an entry's own key | the same, without moving first |
esc, ←, backspace |
back out of a submenu |
? |
what this is, and what it is built on |
q |
quit |
While a command's output is open, the arrows and page up/page down scroll
it and esc closes it.
The menu file
The menu is data, not code: data/menus/main.json describes it, and iTUI reads
it at startup. An entry carries exactly one of items (a submenu), command
(a program and its args) or action ("quit" is the only one so far).
{
"title": "iTUI",
"items": [
{
"key": "s",
"label": "System",
"items": [
{
"key": "d",
"label": "Disk free",
"description": "Free space per mounted filesystem",
"command": "df",
"args": ["-h"]
}
]
},
{ "key": "q", "label": "Quit", "action": "quit" }
]
}
A command is never handed to a shell: the program is resolved with
System.find_executable/1 and its arguments are passed straight to it, so
nothing in a menu file is expanded, globbed or chained. It runs off the UI's
process, so the interface keeps drawing — and stays quittable — while it works.
A menu file that does not parse is reported on screen rather than stopping the application: a terminal that says what is wrong with the file is more use than one that refuses to open.
Schemas, forms and data
A schema file says what a thing holds. The same declaration draws the form and stores the record, so a field added to the file shows up in both:
{
"name": "todo",
"label": "Todo",
"title": "Todos",
"source": "records/todos.json",
"columns": ["id", "done", "priority", "inserted_at", "due", "done_at", "title", "description"],
"sort": "id",
"stretch": "description",
"detail": ["description", "url"],
"fields": [
{ "name": "title", "label": "Title", "type": "string", "required": true },
{ "name": "description", "label": "Description", "lines": 4 },
{ "name": "url", "label": "URL" },
{ "name": "priority", "label": "Priority", "short": "P", "type": "integer", "default": 2 },
{ "name": "due", "label": "Due", "type": "date" },
{ "name": "done", "label": "Done", "type": "boolean", "default": false },
{ "name": "id", "label": "#", "type": "integer", "form": false },
{ "name": "inserted_at", "label": "Created", "type": "datetime", "form": false },
{ "name": "done_at", "label": "Checked", "type": "datetime", "form": false }
]
}
Fields are string, integer, boolean, date or datetime. A boolean is a
toggle in the form and a [x] in the list. A datetime is stored as ISO 8601 in
UTC — which sorts chronologically — and shown in a column as the local day it
fell on, the time of day being in the record for whoever wants it. A date is a
day in a calendar rather than a moment in time, written 2026-09-25, which is
what a due date wants to be: typed by hand, read by the day, and never shifted
by a timezone.
columns says which fields the table draws and in what order: a table reads in
a different order from the form that fills it, and a field left out of them is
shown beside the list instead, which is where a link belongs. Leave columns
out and every field gets one, in the order they are declared. stretch names
the column that takes whatever width the others leave over, and is cut to fit.
detail says outright which fields are shown beside the list, for the row the
cursor is on — a column too narrow to read is worth repeating in full down
there, which is what the description does. Each gets a row of its own, kept
whether or not there is anything in it, so the list does not shuffle up and
down as the cursor moves.
"form": false on a field means it is never asked for, which is what a serial
number and a timestamp the application writes itself need. sort names the
column the list starts sorted by.
lines above one makes the form draw an ITui.TextArea instead of a
single-line field: enter starts a new line there and ctrl-d saves, and what
it holds is shown in one line wherever there is only one. short is for a
label too wide to head a column — the form still says "Priority", the table
says "P".
The serial number is the id the repository gives a record: it counts up, and
it is not handed out twice even when a record is deleted.
source is where ITui.Repo keeps the records — a plain JSON array anyone can
open in an editor. A schema with no source is a form and nothing more, which
is what a command's arguments need.
Records go through one interface, ITui.Repo, with the adapter behind it named
in the configuration:
config :i_tui, repo: ITui.Repo.Json
Ecto without a database
The fields are only known when the file is read, so there is no module to
use Ecto.Schema in — and no need for one. Ecto.Changeset takes a
{data, types} pair as readily as it takes a struct, so ITui.Schema builds
the types out of the fields it parsed and hands Ecto the usual job:
{:ok, schema} = ITui.Schema.load("todo")
ITui.Schema.changeset(schema, %{}, %{"title" => "Write it", "priority" => "1"})
#=> #Ecto.Changeset<changes: %{title: "Write it", priority: 1}, valid?: true>
ITui.Schema.cast(schema, %{"priority" => "high"})
#=> {:error, #Ecto.Changeset<...>}
ITui.Schema.errors/2 turns a changeset into what the form shows under the
rows. A yes/no field is a custom Ecto.Type (ITui.Schema.Boolean) so that
yes, no, y and n mean what a person typing them means.
There is no repository behind the changesets: casting ends in
Ecto.Changeset.apply_action/2, and the JSON file is the database. ecto_sql
is not a dependency, and nothing here talks to one.
The todo list
data/schemas/todo.json and the Todos menu entry are the whole application:
a adds, enter edits, space marks one done, d then y deletes, t
switches the dates between how they are written and how they stand from today,
f chooses which kinds are shown, R re-reads the file. Nothing in ITui.Views.Todo mentions a title or a priority.
The list is a table of the columns the schema declares. ←/→ move the sort
from one column to the next and r turns it the other way up. A column too
wide for the terminal is dropped rather than half-drawn — never the one that
stretches — so the sort is named in the summary line as well as marked in the
header.
t writes every date as it stands from today instead — +3 days, -2 weeks,
today — in the coarsest unit that still means something: days up to a
fortnight, then weeks up to a month, then months up to a year, then years.
A due date can be typed the same way round, because nobody knows what date a fortnight on Tuesday is:
in 3 days · 3 days · 3d · +3d · 3weeks · 1 month · -2w · 2 years
today · tomorrow · yesterday
What gets stored is the day it came to — a due date is a day, not a distance —
so in 2 weeks becomes 2026-10-03 and stays there as the fortnight passes.
Months and years land on the same day of the month, or the last one there is.
Sat 2026-09-19 · 4 todos, 1 done sorted by # ▲
# ▲ │ Done │ P │ Created │ Due │ Checked │ Title
──────┼────────┼─────┼────────────┼────────────┼────────────┼─────────────────────
▸ 2 │ [x] │ 1 │ 2026-09-19 │ 2026-09-16 │ 2026-09-19 │ scheissn geh
3 │ [ ] │ 5 │ 2026-09-19 │ │ │ publish iTUI to HEX
Description: how can I write multiple line inputs and how to edit them
URL: https://iboard.cc
Ticking one off writes the moment it happened into done_at, and unticking it
takes the date away again.
What colour a row is
| green | done |
| red | past its due date |
| yellow | due within two days |
| orange | due within what is left of this calendar week |
| white | due later than that but still this month, or not due on any particular day |
| light blue | due beyond the end of this month |
Those bands are ITui.Band, which is also what f hides and shows and what
itui todo list --only overdue means, so what is being hidden is named the
way the screen already says it — and each is listed with how many
there are of it, because hiding a band of nothing is worth knowing before you
go looking for what moved. The list behind the popup is filtered as the boxes
are ticked rather than when it closes, and the summary reads 5 of 7 todos
while anything is hidden.
Today's date is at the top of the screen, because it is what all of that is reckoned from — the same value the colours are worked out with, not a second reading of the clock. Done wins over everything, so a todo that was overdue turns green when it is ticked rather than staying red. The row the cursor is on keeps its colour and takes a background instead, so the one thing the colour says is not the one thing the cursor hides.
Forms for a command
A command entry that names a "form" asks for its arguments first, and fills
the {{placeholders}} with what the form collected:
{
"key": "p",
"label": "Ping a host",
"command": "ping",
"args": ["-c", "{{count}}", "{{host}}"],
"form": "ping"
}
Layout
data/menus/main.json the menu iTUI ships with
data/schemas/*.json the schemas it ships with
lib/i_tui/data.ex where those files live once it is installed
lib/i_tui/cli.ex the escript, and what it takes on the line
lib/i_tui/cli/ the todo list without a screen, and the help
lib/i_tui/menu.ex the menu file, parsed
lib/i_tui/band.ex what kind of todo a todo is: done, overdue, due soon
lib/i_tui/command.ex a system command, and the running of it
lib/i_tui/schema.ex a schema file, parsed, and its Ecto changesets
lib/i_tui/repo.ex where records live, behind one interface
lib/i_tui/views/ the ATUI views: menu, output, form, todo list, about
Documentation
Generate the docs locally with:
mix docs
Published documentation lives at https://hexdocs.pm/i_tui.
Development
mix deps.get # fetch dependencies
mix test # run the test suite
mix format # format the code
mix docs # build the documentation
License
Copyright (C) 2026 Andreas Altendorfer
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. See LICENSE for the full text.