Skip to content

Plugin gallery

Small plugins to copy into ~/.config/smelt/plugins/. Each one is recorded running in smelt, with a live model doing the work.

Crate lookup

A new tool for the agent: the latest version, downloads and description of a crate, straight from crates.io.

-- ~/.config/smelt/plugins/crates.lua
smelt.tools.register({
  name = "crate_info",
  description = "Latest version and downloads of a crate on crates.io.",
  parameters = {
    type = "object",
    properties = { name = { type = "string" } },
    required = { "name" },
  },
  execute = function(args)
    local url = "https://crates.io/api/v1/crates/" .. args.name
    local headers = { ["user-agent"] = "smelt" }
    local res, err = smelt.http.get(url, { headers = headers })
    if not res or res.status ~= 200 then
      return { content = err or "crate not found", is_error = true }
    end
    local c = smelt.json.decode(res.body).crate
    return ("%s %s, %d downloads: %s"):format(
      c.name, c.max_stable_version, c.downloads, c.description)
  end,
})

Statusline

A lualine-style rebuild of the whole statusline: the mode, the working directory, the branch and counts of added, modified and removed files, with Nerd Font icons.

-- ~/.config/smelt/plugins/lualine.lua
local A, B, C = { 232, 100, 26 }, { 58, 47, 39 }, { 30, 26, 21 }
local FG, DARK = { 232, 223, 214 }, { 23, 20, 15 }
local DIFF = {
  { "added", " ", { 143, 191, 106 } },
  { "modified", " ", { 230, 180, 80 } },
  { "removed", " ", { 239, 107, 91 } },
}
local branch, counts = "", {}

-- Read the branch and count changed files after every tool the agent runs.
local function refresh()
  smelt.spawn(function()
    local snap = smelt.git.diff()
    branch, counts = snap and snap.branch or "", { added = 0, modified = 0, removed = 0 }
    for _, f in ipairs(snap and snap.diff:files() or {}) do
      local kind = (f.status == "?" or f.status == "A") and "added"
        or f.status == "D" and "removed" or "modified"
      counts[kind] = counts[kind] + 1
    end
    smelt.statusline.invalidate()
  end)
end
smelt.events.on("tool_end", refresh)
refresh()

local function text(items, s, bg, fg, bold)
  table.insert(items, { text = s, style = { bg = bg, fg = fg, bold = bold } })
end

smelt.statusline.remove("core")
smelt.statusline.add("line", function()
  local items = {}
  local cwd = smelt.path.basename(smelt.os.cwd())
  text(items, " " .. (smelt.signal.get("agent_mode") or "") .. " ", A, DARK, true)
  text(items, "", B, A)
  text(items, "  " .. cwd .. " ", B, FG)
  text(items, "", C, B)
  text(items, "  " .. branch .. " ", C, FG)
  for _, d in ipairs(DIFF) do
    local n = counts[d[1]] or 0
    if n > 0 then text(items, d[2] .. n .. " ", C, d[3]) end
  end
  return items
end)

Spinner

The working indicator in the rule above the prompt is a source named indicator. The built-in one is a coal that heats with tool calls, beside a gray word that a hammer sweep runs along. Replacing the source draws anything you like. Here status words decode in from noise, hold with the odd flicker and a pink and cyan tear, then corrupt away and give way to the next word. When the turn is done, "done" decodes in one last time and holds, with no dot. Each frame is a pure function of smelt.time.now_ms(), so it repaints the same however often the bar redraws. When the turn is interrupted or paused, the built-in indicator takes over again through builtin("indicator").

glitch.lua
-- ~/.config/smelt/plugins/glitch.lua
local fallback = smelt.prompt_bar.top.builtin("indicator")

-- A glitching status word: each word decodes in from noise, holds with the odd
-- flicker, then corrupts away into the next one. It stays white, with a pink and
-- cyan fringe on the noise. When the turn is done the word "done" decodes in one
-- last time and holds still, with no dot.
local DONE = "done"
local WORDS = { "parsing", "linking", "patching", "resolving", "compiling" }
local WIDTH = 9
local CYCLE_MS = 2600
local DECODE_MS = 520
local CORRUPT_MS = 440
local FRAME_MS = 70
local NOISE = { "░", "▒", "▓", "█", "#", "%", "&", "@", "$", "/", "<", ">", "=", "+", "*", "?" }

local function palette()
  if smelt.theme.is_light() then
    return {
      text = { 40, 40, 45 },
      dark = 60,
      bright = 150,
      magenta = { 200, 30, 120 },
      cyan = { 0, 140, 170 },
    }
  end
  return {
    text = { 240, 240, 245 },
    dark = 170,
    bright = 255,
    magenta = { 240, 70, 165 },
    cyan = { 40, 215, 235 },
  }
end

-- Repeatable noise in 0..1, so a frame looks the same however often it repaints.
local function noise(a, b)
  local x = math.sin(a * 12.9898 + b * 78.233) * 43758.5453
  return x - math.floor(x)
end

local function working()
  local state = smelt.signal.get("work_state")
  return state == "working" or state == "retrying" or state == "busy"
end

-- Noise is mostly a random gray; now and then it fringes pink or cyan.
local function static(colors, a, b)
  local pick = noise(a * 3, b + 13)
  if pick < 0.12 then return colors.magenta end
  if pick < 0.24 then return colors.cyan end
  local level = math.floor(colors.dark + (colors.bright - colors.dark) * noise(a, b + 17))
  return { level, level, level }
end

-- One frame of `word`: each letter is noise until it decodes in. While `live`,
-- the word also flickers, tears, and corrupts away at the end of the cycle.
local function render(colors, word, width, phase, round, live)
  local frame = math.floor(smelt.time.now_ms() / FRAME_MS)

  -- Now and then the whole word tears into a magenta and cyan split.
  local torn = live and phase > DECODE_MS and noise(frame, round + 3) > 0.96
  -- A single letter flickers while the word holds.
  local flicker = live and phase > DECODE_MS and noise(frame, round + 7) > 0.9
    and math.floor(noise(frame, round + 11) * #word) + 1

  local runs = {}
  for i = 1, width do
    local letter = word:sub(i, i)
    local color = colors.text
    local garbled = false
    if phase < DECODE_MS * 0.9 * (i / #word) then
      garbled = true
    elseif live and phase > CYCLE_MS - CORRUPT_MS then
      garbled = noise(i, round) < (phase - (CYCLE_MS - CORRUPT_MS)) / CORRUPT_MS
    elseif flicker == i then
      garbled = true
    end
    if letter == "" then
      letter = " "
    elseif garbled then
      letter = NOISE[math.floor(noise(i + frame, round) * #NOISE) + 1]
      color = static(colors, i, frame)
    elseif torn then
      color = noise(i, round) < 0.5 and colors.magenta or colors.cyan
    end
    runs[#runs + 1] = { text = letter, style = { fg = color, bold = true } }
  end
  return { { runs = runs, priority = 1 } }
end

-- Nothing else repaints a finished turn, so a timer drives the decode.
local done_at, decoding
smelt.signal.subscribe("work_state", function(state)
  if state ~= "done" then
    done_at = nil
    return
  end
  done_at = smelt.time.now_ms()
  if decoding then decoding:remove() end
  decoding = smelt.timer.every(FRAME_MS, function() smelt.prompt_bar.top.invalidate() end)
  smelt.timer.set(DECODE_MS + FRAME_MS, function()
    decoding:remove()
    decoding = nil
  end)
end)

smelt.prompt_bar.top.add("indicator", function()
  if smelt.signal.get("work_state") == "done" and not smelt.signal.get("auto_continue_status") then
    local phase = done_at and smelt.time.now_ms() - done_at or DECODE_MS
    return render(palette(), DONE, #DONE, phase, 0, false)
  end
  if not working() then return fallback() end
  local now = smelt.time.now_ms()
  local round = math.floor(now / CYCLE_MS)
  return render(palette(), WORDS[round % #WORDS + 1], WIDTH, now % CYCLE_MS, round, true)
end)

Changes sidebar

A file tree of the working copy beside the usual layout, with each file's added and removed lines, refreshed after every tool the agent runs. It wraps the default layout from require("smelt.layout").compose instead of rebuilding it.

-- ~/.config/smelt/plugins/changes.lua
local layout = smelt.ui.layout
local default = require("smelt.layout").compose
local files = smelt.win.new(smelt.buf.new(), { surface = "list_inert", wrap = false, pad_left = 1 })
-- A solid panel in the scroll pill's color.
files:row_highlights({ { start = 0, ["end"] = 1000, hl_group = "SmeltScrollPillBg" } })

-- Re-read the working tree after every tool the agent runs.
local function refresh()
  smelt.spawn(function()
    local snap = smelt.git.diff()
    files:document(snap and snap.diff:tree():document())
  end)
end
smelt.events.on("tool_end", refresh)
refresh()

layout.set(function(state)
  return layout.hbox({
    { default(state), width = "fill" },
    { layout.leaf(files), width = 34 },
  })
end)

Snake

Built in: /snake (or F11) opens a snake you steer with hjkl or the arrows while the agent works. The title keeps the score and the session's best.

snake.lua
-- /snake (or F11) - snake in a floating window. hjkl or the arrows steer,
-- space starts over, q, Esc or Ctrl-C closes.
-- The title shows the score and the best one this session.
--
-- Two board rows share one terminal row: `▀` paints the top half in its
-- foreground and the bottom half in its background.

local W, H = 32, 32
local TICK_MS = 100
local FOOD = { rgb = { 240, 80, 60 } }
local STEP = { up = { 0, -1 }, down = { 0, 1 }, left = { -1, 0 }, right = { 1, 0 } }
local OPPOSITE = { up = "down", down = "up", left = "right", right = "left" }

local session = smelt.state.get("smelt.snake")
local game, ui, show

local function key(x, y) return y * W + x end

-- A random free cell, or nil once the snake fills the board.
local function place_food(snake)
  local taken, free = {}, {}
  for _, p in ipairs(snake) do taken[key(p[1], p[2])] = true end
  for i = 0, W * H - 1 do
    if not taken[i] then free[#free + 1] = i end
  end
  if #free == 0 then return nil end
  local i = free[math.random(#free)]
  return { i % W, i // W }
end

local function new_game()
  local y = H // 2
  local snake = { { 7, y }, { 6, y }, { 5, y } } -- head first
  game = { snake = snake, dir = "right", next_dir = "right", food = place_food(snake), score = 0, dead = false }
end

local function tick()
  if game.dead then return end
  game.dir = game.next_dir
  local head, d = game.snake[1], STEP[game.dir]
  local x, y = head[1] + d[1], head[2] + d[2]
  local hit = x < 0 or y < 0 or x >= W or y >= H
  -- The tail moves out of the way this tick, so it is not an obstacle.
  for i = 1, #game.snake - 1 do
    local p = game.snake[i]
    if p[1] == x and p[2] == y then hit = true end
  end
  if hit then
    game.dead = true
    return show()
  end
  table.insert(game.snake, 1, { x, y })
  if game.food and x == game.food[1] and y == game.food[2] then
    game.score = game.score + 1
    if game.score > (session.best or 0) then session.best = game.score end
    game.food = place_food(game.snake)
    show()
  else
    table.remove(game.snake)
  end
end

-- The head is bright and the body fades toward the tail.
local function shade(i)
  local f = 1 - (i - 1) / math.max(#game.snake, 8) * 0.7
  return { rgb = { math.floor(120 * f), math.floor(220 * f), math.floor(90 * f) } }
end

local function paint(slice)
  if not game then return end
  local bg = smelt.theme.get("SmeltScrollPillBg").bg
  local cells = {}
  for i, p in ipairs(game.snake) do cells[key(p[1], p[2])] = shade(i) end
  if game.food then cells[key(game.food[1], game.food[2])] = FOOD end
  local cols, rows = math.min(W, slice:width()), math.min(H // 2, slice:height())
  slice:fill_rect(0, 0, cols, rows, " ", { bg = bg })
  for row = 0, rows - 1 do
    for x = 0, cols - 1 do
      local top, bottom = cells[key(x, row * 2)], cells[key(x, row * 2 + 1)]
      if top or bottom then slice:set(row, x, "▀", { fg = top or bg, bg = bottom or bg }) end
    end
  end
end

local function turn(dir)
  return function()
    if not game.dead and game.dir ~= OPPOSITE[dir] then game.next_dir = dir end
  end
end

local function restart()
  new_game()
  show()
end

local function close()
  if not ui then return end
  ui.timer:remove()
  ui.paint:remove()
  ui.win:close()
  ui, game = nil, nil
end

-- Re-opening the named overlay refreshes its title with the score.
show = function()
  local title = (" snake %d · best %d "):format(game.score, session.best or 0)
  if game.dead then title = title .. "· game over " end
  local layout = smelt.ui.layout
  smelt.overlay.new({
    name = "smelt.snake",
    title = title,
    border = { all = "Comment" },
    anchor = "screen_at", corner = "ne", row = 1, col = 0,
    width = W + 2, height = H // 2 + 2,
    -- The empty window holds focus so the keymaps fire; the board is a paint.
    layout = layout.vbox({
      { layout.leaf(ui.win), height = 0 },
      { layout.leaf(ui.paint, { measure = { W, H // 2 } }), height = "fit" },
    }),
    modal = false,
    draggable = true,
    resizable = false,
    keymaps = {
      { key = "h", on_press = turn("left") },
      { key = "j", on_press = turn("down") },
      { key = "k", on_press = turn("up") },
      { key = "l", on_press = turn("right") },
      { key = "<Left>", on_press = turn("left") },
      { key = "<Down>", on_press = turn("down") },
      { key = "<Up>", on_press = turn("up") },
      { key = "<Right>", on_press = turn("right") },
      { key = "<Space>", on_press = restart },
      { key = "q", on_press = close },
      { key = "<Esc>", on_press = close },
      { key = "<C-c>", on_press = close },
    },
  })
end

local function open()
  math.randomseed(os.time())
  new_game()
  ui = { win = smelt.win.new(smelt.buf.new(), { surface = "readonly_text", hide_cursor = true }) }
  ui.paint = smelt.paint.register(paint, { name = "smelt.snake.paint" })
  show()
  ui.win:focus()
  ui.timer = smelt.timer.every(TICK_MS, tick)
end

local function toggle()
  if ui then close() else open() end
end

smelt.cmd.register("snake", toggle, { desc = "play snake" })
smelt.keymap.set("", "<F11>", toggle)