sumi
Chapters

Getting started

Sumi is a declarative TTY framework for Go. You write .sumi single-file components — a <script> of Go, a <style> block of CSS, and an HTML template — and the sumi tool compiles them to plain Go source that renders to a terminal cell grid.

This chapter takes you from an empty directory to a running, hot-reloading app.

Installing the CLI

On macOS or Linux with Homebrew:

brew install tomyan/tap/sumi

This installs the sumi binary together with the framework source it scaffolds against — nothing else to set up.

Alternatively, install from a checkout of the repository:

git clone https://github.com/tomyan/sumi
cd sumi
go install ./cmd/sumi

That puts sumi on your PATH (in $(go env GOPATH)/bin). Every scaffolded app depends on the framework source through a replace directive — the brew install points it at the copy shipped with the formula; with a manual install it points back at your checkout, so keep it around — see Scaffolding an app.

Scaffolding an app

sumi init myapp

sumi init refuses a non-empty directory. It writes three files, runs code generation, and runs go mod tidy:

  • app.sumi — your component (walked through below).
  • main.go — the entry point.
  • go.mod — the module file.

The module path defaults to example.com/<dir>; pass --module you/app to set your own. The generated go.mod requires the framework and redirects it to your local checkout:

module example.com/myapp

go 1.25

require github.com/tomyan/sumi v0.0.0

replace github.com/tomyan/sumi => /path/to/your/sumi/checkout

The checkout path is located automatically: sumi init walks up from the current directory looking for sumi’s own go.mod, or reads the SUMI_PATH environment variable, or takes --sumi-path. If none resolve, it stops and tells you to set one.

main.go is small and stable — you rarely touch it:

package main

import "github.com/tomyan/sumi/runtime/tui"

//go:generate sumi generate .

func main() {
	tui.Run(NewApp(AppProps{}))
}

The //go:generate line means go generate regenerates the compiled Go from your .sumi files; sumi generate . does the same directly.

The scaffold component

app.sumi has three sections. Here is the whole file, then a walk through each part.

<script>
count := sumi.New(0)

func increment(evt *sumi.DOMEvent) {
	count.Update(func(n int) int { return n + 1 })
}

func handleKey(evt sumi.Event) {
	if evt.Kind == sumi.EventSignal { sumi.Quit(); return }
	if evt.Rune == 'q' || (evt.Ctrl && evt.Rune == 'c') { sumi.Quit(); return }
}
</script>

<style>
h1 {
	color: cyan;
}
button:focus {
	color: yellow;
}
.hint {
	opacity: dim;
}
</style>

<div onkey="handleKey">
	<h1>Hello, sumi</h1>
	<p>You have pressed the button <strong>{count}</strong> times.</p>
	<button onclick={increment}>Press me</button>
	<div class="hint">Tab to focus, Enter to press; q quits</div>
</div>

The script

The <script> block is Go. count := sumi.New(0) creates a signal — a reactive container for a value. Reading it in the template ({count}) subscribes that part of the UI to it; calling count.Set(...) or count.Update(...) marks the UI dirty and schedules a re-render.

Functions in the script are event handlers. increment takes a *sumi.DOMEvent because it is wired to onclick; handleKey takes a sumi.Event (a raw input event) because it is wired to the onkey attribute. sumi.Quit() exits the app; sumi.EventSignal is the terminal telling the app to stop (Ctrl+C at the OS level, window close).

The style

The <style> block is scoped CSS with a user-agent stylesheet underneath, so HTML elements already have sensible terminal defaults. Selectors, the cascade, and the supported property set are covered in Selectors; the :focus pseudo-class here styles whichever control currently holds focus, and opacity: dim maps to the terminal dim attribute.

The template

The template is HTML. {count} interpolates a signal’s value into text. onclick={increment} binds the click handler (curly braces pass the function itself); onkey="handleKey" binds a raw key handler. <button> is focusable and activates on Enter; Tab cycles focus between controls. The element vocabulary is documented in Elements, and how boxes are sized and placed in Layout.

Deviation: the primitive <box> and <text> tags were removed — use <div> (or any container element) and <span> (or any text-level element). Self-closing tags are written <input />, but a container you want to keep as a box must use an explicit close tag: <div></div>, not <div /> for content.

Running

cd myapp
go run .

go run . builds and runs the generated Go. Press q or Ctrl+C to quit.

The dev loop

sumi dev runs a supervisor that rebuilds and relaunches your app whenever a .sumi or .go file under the directory changes:

sumi dev

It shows a status bar and mirrors your app inside it. The watcher polls the tree every 300ms. On save it regenerates, rebuilds, and swaps in the new binary. If a build fails, the bar shows the first line of the compiler error and the message “(last good build still running)” — the previous working version keeps running, so a syntax error never drops you out of the app. When your app exits, sumi dev exits with it.

sumi dev also opens an inspect socket at <appdir>/.sumi-dev.sock, which the next command uses.

Inspecting the tree

While sumi dev is running, from another shell in the same directory:

sumi inspect tree

This connects to the dev socket and prints the live element tree — tag, id, classes, text content, resolved style summary, and focus/hidden flags:

div
  h1 "Hello, sumi" {color:cyan}
  p
    #text "You have pressed the button "
    strong "0" {bold}
    #text " times."
  button :focus
    #text "Press me"
  div.hint
    #text "Tab to focus, Enter to press; q quits"

Container elements (div, button, and the other box-form tags) hold their text in an implicit untagged #text child, which is why “Press me” appears under button rather than beside it. The style summary reports colour, bold, italic, and underline; other attributes such as dim are applied but not summarised.

sumi inspect boxes adds the laid-out geometry to each node (@x,y WxH), which is useful when a box is not where you expect. Pass --json for the raw tree, or --dir <path> / --socket <path> to target a specific dev session.