A TUI framework for Odin;
- alloy - full-screen TUI apps using the Elm Architecture (init / update / view)
- forge - inline CLI wizards; step-by-step prompts that stay in the terminal scroll history
forge v1.0.0 — forge is stable and production ready. alloy is still in early development; APIs will change and features are incomplete.
curl -fsSL https://raw.githubusercontent.com/CRBroughton/alloy/master/install-alloy.sh | shcurl -fsSL https://raw.githubusercontent.com/CRBroughton/alloy/master/install-forge.sh | shInspired by Bubble Tea. Full-screen, alternate-screen buffer, event loop driven.
package main
import "core:fmt"
import alloy "vendor/alloy"
Model :: struct { count: int }
my_init :: proc() -> (^Model, alloy.Cmd) {
return new(Model), nil
}
my_update :: proc(m: ^Model, msg: alloy.Msg) -> (^Model, alloy.Cmd) {
if km, ok := msg.(alloy.KeyMsg); ok {
if km.key == .CtrlC do return m, alloy.quit
if km.key == .Rune && km.rune == '+' do m.count += 1
}
return m, nil
}
my_view :: proc(m: ^Model) -> string {
return fmt.tprintf("Count: %d (+ to increment, Ctrl+C to quit)\r\n", m.count)
}
main :: proc() {
alloy.run(&alloy.Program(Model){
init = my_init,
update = my_update,
view = my_view,
})
}Single-line text field with cursor, placeholder, and focus state.
alloy-text-input.webm
Keyboard-navigable option list; returns SelectDoneMsg on confirm.
alloy-select.webm
Animated indicator driven by a self-scheduling SleepCmd timer.
alloy-spinner.webm
Yes/No prompt with configurable default; returns ConfirmMsg on answer.
alloy-confirm.webm
Checkbox list; Space to toggle, Enter to confirm; returns MultiSelectDoneMsg.
alloy-multiselect.webm
Bordered container with title and four border styles (Rounded, Single, Double, Heavy).
alloy-box.webm
Dispatch multiple commands from a single init or update call.
alloy-batch.webm
Known-duration progress indicator driven by a 0.0–1.0 float in the model.
alloy-progress-bar.webm
Multi-line text editor with (row, col) cursor, Enter to split lines, Backspace to merge.
alloy-textarea.webm
Column-based layout with fr and Fixed track types and configurable gap.
Use sleep to schedule any delayed message, not just spinners:
import "core:time"
// Deliver a custom message after 2 seconds:
return m, alloy.sleep(2 * time.Second, MyTimeoutMsg{})sleep accepts any value that satisfies the Msg union; define your own message types in your app.
update returns a single Cmd. Use batch to return multiple at once:
app_init :: proc() -> (^Model, alloy.Cmd) {
m := new(Model)
alloy.spinner_init(&m.spinner, 1)
return m, alloy.batch(
alloy.spinner_start(&m.spinner),
proc() -> alloy.Msg { return fetch_data() },
)
}nil entries are silently dropped; safe to pass conditional commands without pre-checking:
return m, alloy.batch(
alloy.spinner_start(&m.spinner),
needs_fetch ? fetch_cmd : nil,
)Inspired by Clack. Inline wizard prompts; no alternate screen. Each completed step stays visible in the terminal.
forge-multi-select.webm
Chain prompts sequentially; check .status after each step to handle cancellation:
package main
import "core:fmt"
import forge "vendor/forge"
main :: proc() {
name := forge.text("Project name?", "my-app")
if name.status == .Cancelled do return
framework := forge.pick("Framework?", []forge.SelectOption{
{label = "React", value = "react"},
{label = "Vue", value = "vue"},
{label = "Svelte", value = "svelte"},
})
if framework.status == .Cancelled do return
install := forge.confirm("Install dependencies?")
if install.status == .Cancelled do return
forge.wizard_end()
fmt.printf("Creating %s with %s...\n", name.value, framework.value)
}- text: single-line text input with optional placeholder; supports
mask = truefor passwords - pick: arrow-key navigable option list; returns the selected value
- confirm: Yes/No toggle with Left/Right arrows and
y/nshortcuts - multi_select: checkbox list; Space to toggle, Enter to confirm; supports
defaultanddescriptionper option
Each prompt returns a StepResult:
StepResult :: struct {
value: string, // display string (single value, or comma-joined for multi-select)
values: []string, // selected values (multi-select only; caller owns; call delete())
status: StepStatus, // .Done, .Cancelled, .Error
}MultiSelectOption extends SelectOption with per-option defaults and descriptions:
extras := forge.multi_select("Extras?", []forge.MultiSelectOption{
{label = "ESLint", value = "eslint", description = "Fast linting", default = true},
{label = "Prettier", value = "prettier", description = "Code formatter"},
{label = "Husky", value = "husky", description = "Git hooks"},
})
if extras.status == .Cancelled do return
defer delete(extras.values)
for v in extras.values {
fmt.printf(" installing %s\n", v)
}Run sequential tasks with a live spinner after your prompts:
SetupCtx :: struct { name: string }
ctx := SetupCtx{name = name.value}
result := forge.tasks(
label = "Setting up project",
ctx = &ctx,
stop_on_error = true,
tasks = []forge.Task(SetupCtx){
{
label = "Create directory",
run = proc(c: ^SetupCtx) -> bool {
return os.make_directory(c.name) == nil
},
},
{
label = "Init git repository",
run = proc(c: ^SetupCtx) -> bool {
res := forge.exec({"git", "init", c.name})
defer forge.exec_result_destroy(&res)
return res.success
},
},
},
)
if result.status == .Error do return
forge.wizard_end()Run shell commands from inside task procs.
res := forge.exec({"pnpm", "install"})
defer forge.exec_result_destroy(&res)
return res.successPass an optional working directory as the second argument:
res := forge.exec({"git", "init"}, project_name)
defer forge.exec_result_destroy(&res)
return res.successExecResult fields:
ExecResult :: struct {
exit_code: int,
success: bool,
stdout: string,
stderr: string,
}Always call exec_result_destroy when done; it frees the captured stdout and stderr strings.
Write compile-time-embedded template files into a project directory, mirroring the path structure. Subdirectories are created as needed:
ok := forge.copy_embedded(project_name, embedded_paths(), embedded_read)
if !ok do return falsecopy_embedded is decoupled from any specific embed implementation; it takes a paths slice and a read proc, so it works with any code-generated embed layer:
copy_embedded :: proc(
project: string,
paths: []string,
read: proc(_: string) -> ([]byte, bool),
) -> boolSee examples/forge/installer/ for a full example using embed-gen (src/embed/) to generate the embed layer at build time.
just test