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.