Lua extension tutorial
How to draw a sidebar
blitz.draw gives Lua rectangles of
the screen. A sidebar spans the full terminal height on one side. A panel
attaches to the input box and follows it. Pass a render callback to either one,
and the callback paints every frame. My panel shows the main agent's todos on
the left and one row per sub-agent on the right.
1. Reserve the region
Both calls take a render callback and return a handle with
show(),
hide(),
remove(), and
set_size(cells). Coordinates are
widget-relative, (0,0) is the top-left corner. Writes outside the rect clip
silently instead of crashing the frame.
~/.config/blitzdenk/draw.lua
-- a panel above the input box
panel = blitz.draw.panel({
height = 3,
place = "between", -- "below" sits it under the input
render = function(w, h, buf, frame)
buf.fill(0, 0, w, h, "bg")
buf.box(0, 0, w, h, "muted")
buf.set_color(1, 0, " Todos ", "info")
end,
})
-- the full-height variant
blitz.draw.sidebar({
side = "left",
width = 24,
render = function(w, h, buf)
buf.box(0, 0, w, h, "muted")
buf.set(1, 1, "active")
end,
})
Four buf calls cover most panels.
buf.fill paints a solid
background, buf.box draws a
border, buf.set writes text in
the theme text color, and
buf.set_color writes text in a
theme color or a #RRGGBB hex
string.
2. Fill it with live data
The render callback reads state and paints it, nothing more. The todo list
from the todo tools tutorial
lives in blitz.state under
todo_<agent_id>.
blitz.list_agents() returns one
row per occupied slot with state, context fill, and tokens per second.
Keep the callback pure and fast. No awaits, no file IO, no long work, or frames drop. Cut strings to the column width and drop the last code point when the cut lands inside a multibyte character.
local function cut(s, n)
if #s <= n then
return s
end
s = s:sub(1, math.max(0, n - 1))
while #s > 0 and utf8.len(s) == nil do
s = s:sub(1, #s - 1)
end
return s .. "…"
end
local function main_todos()
local agent = blitz.get_main_agent()
if not agent then
return {}
end
return blitz.state.get("todo_" .. agent) or {}
end
local STATE_COLORS = {
done = "ok",
in_progress = "warn",
pending = "muted",
}
3. Grow with the content
A fixed height wastes space or hides rows. Compare wanted height against
current height each frame and call
set_size on the handle. Hold the
handle in a local the callback closes over.
panel = blitz.draw.panel({
height = 3,
place = "between",
render = function(w, h, buf, frame)
local wanted = math.max(3, #main_todos() + 2, #blitz.list_agents() + 2)
if wanted ~= h then
panel.set_size(wanted)
end
-- paint below
end,
})
4. Blink busy agents
Render callbacks run up to 60 fps while agents work and before the first
prompt is sent. After that the app draws only on input. Ask for frames
yourself. Call
blitz.draw.redraw() at the end
of the render, and the next frame follows. The fourth callback argument
counts frames and resets on session reset, enough for a blink.
local BLINK_FRAMES = 20
-- inside the render callback, per agent row:
local busy = a.state ~= "idle" and a.state ~= "complete"
and a.state ~= "canceled" and a.state ~= "failed"
local icon = busy and "●" or "○"
if busy and math.floor(frame / BLINK_FRAMES) % 2 == 0 then
icon = " "
blinking = true
end
buf.set_color(ax, y, icon, busy and "err" or "muted")
-- at the very end of the render callback:
if blinking then
blitz.draw.redraw()
end
5. The full panel
Two boxes, todos sorted by state on the left, agents on the right. Around 60 lines, hot reloaded while Blitzdenk runs.
draw.lua
local BLINK_FRAMES = 20
local MARK_X, TEXT_X = 1, 5
local GROUPS = {
{ state = "done", color = "ok" },
{ state = "in_progress", color = "warn" },
{ state = "pending", color = "muted" },
}
local MARKS = { done = "[x]", in_progress = "[~]", pending = "[ ]" }
panel = blitz.draw.panel({
height = 3,
place = "between",
render = function(w, h, buf, frame)
local agents = blitz.list_agents()
local wanted = math.max(3, #main_todos() + 2, #agents + 2)
if wanted ~= h then
panel.set_size(wanted)
end
buf.fill(0, 0, w, h, "bg")
local mid = math.floor(w / 2)
buf.box(0, 0, mid, h, "muted")
buf.box(mid, 0, w - mid, h, "muted")
buf.set_color(1, 0, " Todos ", "info")
buf.set_color(mid + 1, 0, " Agents ", "info")
local y = 1
for _, g in ipairs(GROUPS) do
for _, t in ipairs(main_todos()) do
if t.state == g.state and y < h - 1 then
buf.set_color(MARK_X, y, MARKS[g.state], g.color)
buf.set(TEXT_X, y, cut("#" .. t.id .. " " .. t.text, mid - TEXT_X - 1))
y = y + 1
end
end
end
local blinking = false
y = 1
for _, a in ipairs(agents) do
if y > h - 2 then
break
end
local busy = a.state ~= "idle" and a.state ~= "complete"
and a.state ~= "canceled" and a.state ~= "failed"
local icon = busy and "●" or "○"
if busy and math.floor(frame / BLINK_FRAMES) % 2 == 0 then
icon = " "
blinking = true
end
local status = a.state .. " " .. math.floor(a.ctx or 0) .. "%"
buf.set_color(mid + 1, y, icon, busy and "err" or "muted")
buf.set(mid + 3, y, cut(a.name, w - mid - #status - 6) .. " " .. status)
y = y + 1
end
if blinking then
blitz.draw.redraw()
end
end,
})
Keep the render callback drawing only. All data comes from
blitz.state and the list calls.
Use one sidebar per side. A second
sidebar call on the same side
replaces the first and kills its handle.