agentsclimarketplace

Tachikoma tui

Skill csvance/Luximm.jl/skills/tachikoma-tui

Bootstrap guide for building a Tachikoma.jl TUI app. Load when asked to create, scaffold, or extend a terminal UI in Julia using Tachikoma, including any work that touches Model/update!/view, the app() loop, layouts, widgets, or KeyEvent / MouseEvent handling. Covers the minimal app skeleton and the sharp edges that cost the most round trips.From its SKILL.md

Install
npx -y skills add csvance/Luximm.jl --skill tachikoma-tui

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 10 stars10 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

16.3 KB, as published. Nobody here has run it

Tachikoma.jl App Scaffolding

A reference for building a TUI in Tachikoma.jl. Covers the protocol every app implements, the layout primitives, the small set of widgets you reach for first, and the sharp edges that cost time when you hit them blind.

For anything not covered here, the canonical references are the published docs at kahliburke.github.io/Tachikoma.jl/dev and the bundled demos in the Tachikoma.jl repo. Treat this skill as the runway, not the manual.

1. Minimum viable app

Every Tachikoma app does three things: define a mutable Model, implement update! to react to events, and implement view to draw each frame. app(m) runs the loop.

using Tachikoma
@tachikoma_app

@kwdef mutable struct Counter <: Model
    quit::Bool = false
    tick::Int = 0
    n::Int = 0
end

should_quit(m::Counter) = m.quit

function update!(m::Counter, evt::KeyEvent)
    evt.key == :char && evt.char == 'q' && (m.quit = true)
    evt.key == :escape                  && (m.quit = true)
    evt.key == :char && evt.char == '+' && (m.n += 1)
    evt.key == :char && evt.char == '-' && (m.n -= 1)
end

function view(m::Counter, f::Frame)
    m.tick += 1
    inner = render(Block(title="Counter"), f.area, f.buffer)
    set_string!(f.buffer, inner.x + 1, inner.y + 1,
        "n = $(m.n)   [+/-]change [q]uit", tstyle(:primary))
end

app(Counter())

Three things to notice:

  • The struct must be mutable and subtype Model. update! mutates it in place.
  • @tachikoma_app brings the callback names (view, update!, should_quit, init!, cleanup!) into your namespace so your method definitions extend Tachikoma's, instead of shadowing them in a fresh namespace.
  • app(m) enters the alt screen, raw mode, and mouse mode, then runs the 60fps loop. Ctrl+C exits regardless of what update! does.

2. The protocol

All callbacks dispatch on your model type. Only view and update! (or should_quit flag) are typically needed; the rest exist for less common cases.

CallbackRequiredPurpose
view(m, f::Frame)yesDraw into f.buffer within f.area. Called each frame.
update!(m, e::Event)yesReact to input. Dispatch on KeyEvent, MouseEvent, TaskEvent.
should_quit(m)yesReturn true to exit the loop cleanly.
init!(m, t::Terminal)noOne-shot setup; terminal size is known here.
cleanup!(m)noTeardown after the loop exits (terminal already restored).
pre_render!(m) / post_render!(m)noHooks immediately before/after view.
task_queue(m)noReturn a TaskQueue to enable spawn_task! / TaskEvent.
recording_enabled(m)noReturn false to disable Ctrl+R recording for this app.

See architecture for the full lifecycle.

3. Events

update! is dispatched on three concrete event types. Define one method per type you care about.

KeyEvent — fields key::Symbol, char::Char, action::KeyAction.

Common key values: :char, :escape, :enter, :backspace, :tab, :backtab, :up, :down, :left, :right, :home, :end_key, :pageup, :pagedown, :delete, :insert, :f1 through :f12, :ctrl, :ctrl_c. For printable characters, key == :char and char holds the value. Match.jl is idiomatic for non-trivial dispatch:

using Match
function update!(m::MyApp, e::KeyEvent)
    @match (e.key, e.char) begin
        (:char, 'q') || (:escape, _) => (m.quit = true)
        (:char, 'r') || (:enter, _)  => reset!(m)
        _                            => nothing
    end
end

MouseEvent — fields x::Int, y::Int (1-based screen coords), button, action, plus shift/alt/ctrl flags. Buttons include mouse_left, mouse_middle, mouse_right, mouse_scroll_up, mouse_scroll_down. Actions are mouse_press, mouse_release, mouse_drag, mouse_move. Store widget rects on the model and use contains(rect, e.x, e.y) for hit testing.

TaskEvent — fields id::Symbol, value::Any. Delivered when background work spawned via spawn_task!/spawn_timer! completes. Requires a task_queue(m) method. See the async docs.

4. Layout

Layouts are constraint-based and explicit. You divide a Rect into child rects using a vector of constraints.

ConstraintMeaning
Fixed(n)Exactly n cells.
Min(n)At least n cells; grows if room remains.
Max(n)Up to n cells.
Percent(p)p% of the parent rect (integer 0-100).
Fill(w=1)Takes remaining space; w is the relative weight.
Ratio(a, b)a/b of the parent rect.
rows = split_layout(
    Layout(Vertical, [Fixed(1), Fixed(5), Fill(), Fixed(1)]),
    f.area)
length(rows) < 4 && return   # terminal may be too small

cols = split_layout(Layout(Horizontal, [Percent(30), Fill()]), rows[3])

Layouts nest freely: split horizontally, then split each column vertically. Use Layout(Vertical, [...]; spacing=1) to leave a row between children.

Geometry helpers worth knowing (all exported): inner(r) shrinks by 1 on every side, center(parent, w, h) returns a Rect of size w×h centered in parent, shrink(r, n) applies a uniform margin, bottom(r) and right(r) give the last row/column, anchor(parent, w, h; h=:center, v=:center) for corner placement.

5. Rendering primitives

Two ways to put characters on screen, both writing to f.buffer:

set_char!(buf, x, y, '█', tstyle(:primary))
set_string!(buf, x, y, "hello", tstyle(:accent))

Coordinates are 1-based: set_char!(buf, 1, 1, ...) writes the top-left cell. f.area.x and f.area.y are also 1-based.

For anything more complex, call a widget's render:

inner = render(Block(title="Stats"), f.area, buf)
# `inner` is the rect inside the border; draw widgets into it
render(Gauge(0.42; filled_style=tstyle(:primary)), inner, buf)

render(widget, rect, buf) returns the inner rect the widget consumed. For container widgets like Block, that's the space inside the border; for content widgets, it's usually the input rect unchanged. Construct stateless widgets fresh each frame with current data. Persist stateful widgets (see Section 7) on the model.

6. Styling

Use tstyle(name; bold=false, dim=false, italic=false, underline=false) keyed by a theme slot:

tstyle(:primary, bold=true)
tstyle(:text_dim)
tstyle(:error)

Available slots include :primary, :accent, :success, :warning, :error, :border, :title, :text, :text_dim, :background. The active theme controls the actual colors, so an app written with tstyle automatically adapts to all 24 bundled themes and to the user's Ctrl+\ theme switch. Avoid hard-coded Color256/ColorRGB unless drawing pure-aesthetic content.

7. The widgets you'll reach for first

Tachikoma ships 30+ widgets. Five cover most app scaffolds; for the rest see the widgets reference.

Block(title=..., border_style=...) — bordered container. Returns the inner Rect.

inner = render(Block(title="Logs", border_style=tstyle(:border)), area, buf)

StatusBar(left=[Span(...)], right=[Span(...)]) — single-row strip with left/right-aligned spans. Render into a Fixed(1) row, typically rows[end] or pinned to bottom(f.area).

render(StatusBar(
    left=[Span("  [r]oll  ", tstyle(:accent))],
    right=[Span("[q]uit ", tstyle(:text_dim))],
), Rect(f.area.x, bottom(f.area), f.area.width, 1), buf)

Gauge(ratio; filled_style, empty_style, label="") — horizontal progress bar; ratio is 0.0..1.0. Reconstruct each frame.

SelectableList(items; selected=1, focused=true) — keyboard-navigable list. Stateful: store on the model. Use handle_key!(list, evt) to delegate arrow keys; read value(list) for the current item.

Form([FormField("Name", TextInput(...); required=true), ...]; tick) — labeled fields, Tab/Shift-Tab navigation, submit button, validation. Stateful: store on the model. Sync m.form.tick = m.tick each frame so cursors blink. handle_key!(m.form, evt) drives navigation; valid(m.form) and value(m.form) read the result.

For a fuller catalog: MarkdownPane, DataTable, Chart, BarChart, Sparkline, Calendar, Modal, TreeView, Checkbox, RadioGroup, DropDown, Button, TabBar, ScrollPane, Paragraph, BigText, CodeEditor, Canvas, BlockCanvas, PixelImage, FloatingWindow, WindowManager, TerminalWidget, REPLWidget, Separator.

8. Default key bindings

app(m) installs these automatically. Pass default_bindings=false to disable when an app needs the Ctrl+letter range for its own use.

KeyAction
Ctrl+CQuit (always active, can't be disabled)
Ctrl+GToggle mouse mode
Ctrl+\Open theme selector
Ctrl+AToggle animations
Ctrl+SSettings overlay
Ctrl+?Help overlay
Ctrl+RStart/stop .tach recording
Ctrl+YCopy pane to clipboard

9. Sharp edges

These are the mistakes that cost the most round trips. Most are not visible until they bite.

  • Struct must be mutable. update! mutates the model in place. An immutable struct fails with setfield! immutable struct the first time a key arrives.
  • @tachikoma_app (or explicit import Tachikoma: view, update!, should_quit) is required. Without it, function view(::MyModel, ...) defines a fresh view in your module instead of extending Tachikoma's, and the loop never sees it. Symptom: blank screen, no errors.
  • Reconstruct stateless widgets each frame, persist stateful ones. Block, Gauge, Sparkline, BarChart, StatusBar, Paragraph, BigText, Modal, Separator are stateless: build them fresh in view. TextInput, TextArea, CodeEditor, DataTable, Form, SelectableList, TreeView, DropDown, Checkbox, RadioGroup, Button, ScrollPane, REPLWidget, TerminalWidget carry internal state (cursor, scroll, selection, focus); rebuilding them each frame resets it. Store these on the model.
  • Coordinates are 1-based. set_char!(buf, 1, 1, ...) is the top-left. So are f.area.x and f.area.y. Off-by-one bugs at the top edge usually trace to assuming 0-based.
  • split_layout returns fewer rects than constraints if the area is too small. Always guard: length(rows) < N && return before indexing. This happens routinely at app startup when the terminal hasn't reported a size yet, and when the user shrinks the window.
  • Stateful widgets carrying a tick need it synced before render. Pattern: m.form.tick = m.tick (and same for any list/button with cursor animations) at the top of view. Without it, cursors and spinners freeze on the first value.
  • println and print from inside view/update! corrupt the screen. They write directly to the terminal while Tachikoma is composing the frame. Use @info/@debug/@warn (Tachikoma redirects logging through the recording channel) or wire on_stdout / on_stderr callbacks via app(m; on_stdout=..., on_stderr=...).
  • Don't Pkg.activate inside init!. Activate the project before launching the app. Activating during init! causes Pkg side effects to compete with the render loop for the terminal.
  • Wrap app(m) in a function. A bare app(m) at top level blocks the REPL with no way to re-enter cleanly after exit. Standard pattern: function myapp(); app(MyModel()); end, then call myapp().
  • TerminalWidget and REPLWidget own a subprocess or PTY. Close them in cleanup!(m) (call close!(w) or pty_close!) or wrap your app(...) call in try/finally. Otherwise the child process leaks past app exit.
  • Frame.area may not equal terminal size. It's the area the framework gave this view call; on resize events you'll see a different rect mid-frame. Always layout from f.area, never from a cached terminal size.

10. Where to look next

Reference docs (published at kahliburke.github.io/Tachikoma.jl/dev):

PageTopic
getting-startedBuild-your-first-app walkthrough
architectureFull lifecycle, Frame/Buffer internals
layoutConstraint semantics, ResizableLayout
widgetsEvery widget with constructor signatures
eventsEvent types and dispatch in depth
stylingThemes, palettes, color helpers
asyncTaskQueue, spawn_task!, spawn_timer!
animationTweens, springs, organic effects
canvasBraille, block, and pixel rendering
testingTestBackend for headless widget tests
recording.tach format, record_app, SVG/GIF export

Seed templates in the Tachikoma.jl repo, under demos/TachikomaDemos/src/:

  • clock.jl — minimal animation loop with BigText, Calendar, StatusBar. The cleanest "shape of a view" reference.
  • form_demo.jl — Form with validators, FocusRing, submit detection.
  • dashboard.jl — multi-widget layout, simulated data, nested split_layout.
  • life.jl and mouse_demo.jl — init!-based canvas setup, mouse hit testing with contains.
  • snake.jl — game loop with tick-driven simulation.

To run the demos locally, clone Tachikoma.jl, then from its root:

using Pkg
Pkg.activate("demos/TachikomaDemos")
Pkg.instantiate()
using TachikomaDemos
launcher()

Keep looking

Skills are one crate of 628,853. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.