Skip to content

Architecture ​

Tachikoma uses an Elm-inspired architecture: your application state lives in a single Model, events flow through update!, and the UI is rebuilt each frame in view. The framework manages the terminal, event loop, and double-buffered rendering.

The Model/Update/View Pattern ​

event_loop example

Every frame, the framework polls for input, dispatches events to update!, calls view to render the UI into a buffer, then diffs the buffer against the previous frame and writes only the changed cells to the terminal.

1. Define Your Model ​

Model is an abstract type that your application state must subtype. It serves as the dispatch anchor for the framework — the event loop calls update!(model, event) and view(model, frame) on your concrete type. The framework never mutates your model directly; you control all state changes in update!.

julia
@kwdef mutable struct MyApp <: Model
    quit::Bool = false
    tick::Int = 0
    # ... your state fields ...
end

2. Implement the Protocol ​

MethodRequiredDescription
view(model, frame)YesRender the UI into the frame's buffer
update!(model, event)NoHandle keyboard/mouse events
should_quit(model)NoReturn true to exit (default: false)
init!(model, terminal)NoOne-time setup when app starts
cleanup!(model)NoTeardown when app exits
pre_render!(model)NoCalled each frame just before view
post_render!(model)NoCalled each frame just after view
copy_rect(model)NoRect of focused pane for Ctrl+Y copy
task_queue(model)NoReturn a TaskQueue for async integration

3. Run the App ​

julia
app(MyApp(); fps=60, default_bindings=true)

Lifecycle ​

lifecycle_tree example

Stdout Protection ​

During TUI mode, stdout and stderr are automatically redirected to pipes so that background println() calls (from async tasks, test runners, etc.) cannot corrupt the display. Rendering goes to /dev/tty directly, bypassing the redirected file descriptors.

By default, captured output is silently discarded. Pass on_stdout / on_stderr callbacks to receive captured lines — for example, to display them in an activity log:

julia
app(model; on_stdout=line -> push!(my_log, line))

This also works with with_terminal directly:

julia
with_terminal(on_stdout=line -> push!(log, line)) do t
    # background println() calls are captured, TUI is clean
end

The terminal_size() function automatically falls back to stdin for size queries when stdout is not a TTY, so window resize detection works regardless of redirection.

Frame vs Buffer ​

The view function receives a Frame:

julia
function view(m::MyApp, f::Frame)
    buf = f.buffer    # Buffer — the 2D cell grid you write into
    area = f.area     # Rect — the full terminal area
    # f.gfx_regions — for pixel data (advanced)
end
  • Buffer — A 2D grid of styled characters. Use set_char!, set_string!, and widget render calls to fill it.

  • Frame — Wraps the buffer plus the terminal area and graphics regions. Passed to view and to render(widget, rect, frame) for widgets that produce raster output.

Most widgets render to the buffer:

julia
render(widget, rect, buf)      # buffer-based (most widgets)
render(widget, rect, frame)    # frame-based (pixel widgets use this)

The Render Dispatch ​

All widgets implement render(widget, area::Rect, buf::Buffer). Some widgets (like Block) return the inner Rect after drawing borders:

julia
inner = render(Block(title="Panel"), area, buf)
set_string!(buf, inner.x, inner.y, "Content here", tstyle(:primary, bold=true))
set_string!(buf, inner.x, inner.y + 1, "rendered inside inner Rect", tstyle(:text_dim))
render_dispatch_demo example

AppOverlay and Default Bindings ​

When default_bindings=true (the default), the framework intercepts certain key combinations before they reach your update!:

  • Ctrl+G — Toggle mouse mode

  • Ctrl+T (or Ctrl+\ on US layouts) — Theme selector overlay

  • Ctrl+A — Toggle animations

  • Ctrl+S — Settings overlay (render backend, decay, background)

  • Ctrl+? — Help overlay

  • Ctrl+Y — Copy focused pane to clipboard

These overlays render on top of your view. When an overlay is open, it consumes all key events until dismissed.

Override copy_rect(model) to return the Rect of a specific pane for Ctrl+Y:

julia
function copy_rect(m::MyApp)
    m.pane_rects[m.focused_pane]  # copy just this pane
end

Return nothing (the default) to copy the full screen.

Clipboard Support ​

julia
clipboard_copy!(text::AbstractString; io=stdout, backend=clipboard_backend()) → Symbol
clipboard_copy!(t::Terminal, text::AbstractString) → Symbol

Copies text to the system clipboard and returns the backend that took it: :native, :osc52, or :none if nothing worked. It never throws.

There are two backends, because neither covers every environment:

BackendHow it worksLimits
:nativePipes to a helper program — pbcopy on macOS, wl-copy/xclip/xsel on Linux and BSD, PowerShell Set-Clipboard on WindowsNeeds the helper installed; writes to the clipboard of the machine Julia runs on
:osc52Writes an OSC 52 escape sequence that the terminal emulator handles itselfNeeds terminal support, and gives no acknowledgement — a :osc52 result means "sent", not "landed"

Candidates are tried until one succeeds, so a helper that is installed but cannot reach a display server — xclip in a Wayland-only session, for instance — falls through to the next one. wl-copy is tried first when WAYLAND_DISPLAY is set.

The default :auto uses the native backend first, except when frames are going to another machine's terminal (SSH, or a tty_path/io sink), where a helper would write to the wrong machine's clipboard and OSC 52 goes first.

To force a backend:

julia
set_clipboard_backend!(:osc52)   # or :native, :auto, :none

Or set TACHIKOMA_CLIPBOARD=osc52 in the environment before startup. Force :osc52 when no clipboard helper is installed; force :native if your terminal mishandles OSC 52.

Under tmux, OSC 52 relies on set-clipboard being on or external (the default). GNU screen does not forward OSC 52 at all — use the native backend there.

julia
buffer_to_text(buf::Buffer, rect::Rect) → String

Extracts visible text from a buffer region — used internally by Ctrl+Y but available for custom clipboard operations.