Skip to content

UI

All of these are UiHost APIs: they need the terminal UI and raise in headless mode.

Overlays

An overlay is a floating or docked window. Create a buffer, attach it to a window, and mount the window in an overlay:

local buf = smelt.buf.new()
local win = smelt.win.new(buf, { surface = "inert" })

smelt.overlay.new({
  title     = { { text = " perf ", bold = true } },
  anchor    = "screen_at",
  corner    = "ne",
  width     = 44,
  height    = 14,
  modal     = false,
  draggable = true,
  layout    = smelt.ui.layout.leaf(win),
})

Anchor decides where the overlay lives:

Anchor Placement
dock_bottom (default) Docked above the statusline
dock_top, dock_left, dock_right Docked to that edge (statusline row stays reserved)
center Centered on screen
screen_at Absolute; pair with corner, row, col
win Attached to another window; pair with target, attach, row_offset, col_offset

Size: width and height are fixed; max_width and max_height shrink to fit up to a cap. Don't set both fixed and max on one axis. Values are a number of cells, a percentage string like "50%", or "fill".

Dialogs

For a modal with a markdown panel, an option list and a text input, smelt.dialog.open is the higher-level API and returns the user's choice. The bundled dialogs in runtime/lua/smelt/dialogs/ (confirm, permissions, resume, rewind) are the reference implementations.

Dialog height is either height = "N%" (fixed) or max_height = "N%" (shrink to fit, capped). With neither, the dialog fits its content. Setting both is an error.

Layouts and resizable splits

Compose windows with hbox, vbox and leaf nodes. hsplit(first, second, opts) puts two subtrees side by side and vsplit stacks them, each with a draggable divider.

local layout = smelt.ui.layout
local files = smelt.win.new(smelt.buf.new(), { surface = "list_inert" })
local changes = smelt.win.new(smelt.buf.new(), { surface = "readonly_text" })
local body = layout.hsplit(layout.leaf(files), layout.leaf(changes), {
  size = 34, min_first = 26, min_second = 32,
})
smelt.dialog.new({ layout = body, height = 20, focus = files })

The same body works in smelt.overlay.new({ layout = body }). size is the first pane's initial extent in cells, a percentage, or ratio:N/M (default "50%"). By default a user-resized split keeps its ratio when the terminal changes; pass resize = "cells" to keep a fixed width instead.

If you rebuild the layout from a composer function, create the split once with layout.split("horizontal", opts) and call split:layout(first, second) inside the composer, so the user's chosen size is kept. split:size() and split:set_size() save and restore it.

layout.frame(body, { border = "single", padding = 1, title = "details" }) adds chrome around any subtree.

win:resize("width", 4) and win:resize("height", -1) resize the nearest split (or the containing overlay), and win:equalize() balances splits. The built-in keys are Ctrl-W >, <, +, - and =.

smelt.dialog.new and smelt.dialog.open accept either a composed layout or the simpler panels / bottom_panels recipe, not both.

The main screen

smelt.ui.layout.set(fn) replaces the composer for the whole screen. The default one is require("smelt.layout").compose, so a plugin can wrap it rather than rebuild it, for example to put a sidebar beside everything:

local layout = smelt.ui.layout
local default = require("smelt.layout").compose
local side = smelt.win.new(smelt.buf.new(), { surface = "list_inert" })
layout.set(function(state)
  return layout.hbox({
    { default(state), width = "fill" },
    { layout.leaf(side, { border = "rounded", title = " notes " }), width = 30 },
  })
end)

The changes sidebar in the gallery is a complete example.

Large read-only views

For big read-only content, give a window a document instead of filling a buffer with every line. Only the visible rows are materialized, so a million-line diff stays responsive.

smelt.spawn(function()
  local snapshot, err = smelt.git.diff()
  if not snapshot then smelt.notify.error(err); return end
  local buf = smelt.buf.new()
  local win = smelt.win.new(buf, { surface = "readonly_text", vim_enabled = true })
  win:document(snapshot.diff:document())
  smelt.overlay.new({
    anchor = "center", width = "90%", height = "80%", modal = true,
    layout = smelt.ui.layout.leaf(win),
  })
  win:focus()
end)
  • smelt.document.text(text) builds a document from plain text; smelt.diff.parse(patch) from a unified patch. smelt.git.diff() loads and indexes off the UI thread.
  • A document can back several windows, each with its own scroll and cursor. win:document(nil) restores the original buffer.
  • Syntax highlighting arrives in the background; text and navigation work immediately.
  • smelt.git.index(diff, index, action) stages or unstages one file ("stage", "unstage" or "toggle") without touching the worktree, and returns a fresh snapshot.

The bundled smelt.plugins.diff plugin shows the full pattern with a file tree, folding and refresh.

Pickers

smelt.picker.open(opts) asks the user to choose one item. Items are strings or { label, description, ansi_color, search_terms } records. With placement = "prompt_docked" the prompt filters them using smelt.fuzzy.rank. It returns { index, item, action }, or nil when dismissed.

smelt.spawn(function()
  local choice = smelt.picker.open({
    items = { "first", "second", "third" },
    placement = "prompt_docked",
  })
  if choice then smelt.log.info("picked " .. choice.item) end
end)

Window events and marks

To decorate an existing buffer, subscribe to window events and draw marks in a namespace you own:

local prompt = smelt.prompt.win()
local ns     = smelt.ns("my_plugin")

prompt:on("text_changed", function()
  local buf = prompt:buf()
  if not buf then return end
  buf:clear_ns(ns):mark(ns, 1, 0, {
    end_col  = 999,
    hl_group = "DiagnosticHint",
    priority = 200,
  })
end)

Event names and mark options are type-checked in your editor.

Paint leaves and pointer events

For canvas-like overlays that don't need a buffer, register a paint function and handle pointer events directly. The leaf that received press keeps receiving drag and release even if the pointer leaves its rectangle.

local paint = smelt.paint.register(draw_fn, { name = "myplugin.paint" })
paint:on("press",   function(ev) smelt.notify.info("down @ "..ev.row..","..ev.col) end)
paint:on("drag",    function(ev) ... end)
paint:on("release", function(ev) ... end)

Statusline and prompt bars

The statusline and the two rules around the prompt are bars. A bar holds named sources: functions that run on every repaint and return a list of items. smelt lays the items out by priority and drops the least important ones when the line is narrow.

Bar Where Built-in sources
smelt.statusline The bottom row core
smelt.prompt_bar.top The rule above the prompt pause, indicator, elapsed, retry on the left; model, tokens, cost on the right
smelt.prompt_bar.bottom The rule below the prompt None
smelt.statusline.add("clock", function()
  return { text = os.date("%H:%M"), align_right = true, priority = 2 }
end)

smelt.prompt_bar.bottom.add("hint", function()
  return { text = "ctrl+s stash", style = { fg = "Comment" }, align_right = true }
end)

A source returns one item or a list of them. Each item is a table:

Field Meaning
text What to show. Items with empty text are skipped
runs A list of { text, style } pieces instead of text, for coloring each character; the pieces drop together
style fg, bg, hl_group, bold, dim, italic; colors are theme group names or { r, g, b }
priority Higher drops first when the line is narrow; 0 (the default) never drops
align_right Pack against the right edge
separated Put a · between this item and the previous separated one
truncatable, truncate Shrink the text with … before dropping it, from the end or the "middle"
selectable Include the text in drag-copy (default true)

Neighbouring items get one space between them. The prompt bars draw the rule and its edges themselves, so a source only returns what sits on it.

Adding a name that already exists replaces that source where it stood, so add("model", fn) swaps the model display and keeps its place. remove(name) drops a source, and add returns a Reg that removes the one it added. builtin(name) returns a built-in source, so a replacement can fall back to it for the cases it does not draw:

local fallback = smelt.prompt_bar.top.builtin("indicator")

smelt.prompt_bar.top.add("indicator", function()
  if smelt.signal.get("work_state") ~= "working" then return fallback() end
  return { text = "thinking", style = { fg = "SmeltAccent" }, priority = 1 }
end)

smelt.prompt_bar.top.remove("cost")

The bar above the prompt repaints on every frame while the agent works, so a source can animate from smelt.time.now_ms(). After the turn ends nothing animates, so a source that keeps moving, like the built-in indicator cooling its dot, repaints with a short smelt.timer. A source that depends on state of its own calls invalidate() on its bar when that state changes. A failing source is reported on stderr and skipped.