Files
community-plugins/claude-companion/answer.luau
T
Jarred RobinsonandGitHub 14215c8cd4 feat: add claude-companion (#33)
* Add lowcache/claude-companion plugin for PR

* claude-companion: address review feedback

...

* Added tr & timeout to dependency list, and reduced thumbnail description

* added missing thumbnail.webp
2026-07-19 08:46:30 -04:00

88 lines
3.5 KiB
Luau

-- The answer panel — the full-length surface for /claude ? quick-ask answers.
-- A notification toast clips long bodies and cannot scroll, so claude.luau publishes
-- the complete answer to noctalia.state "claude.answer" and this panel renders it
-- wrapped and scrollable. Ways in: click the bar pulse, the "Show last answer"
-- row under /claude, or `noctalia msg panel-open lowcache/claude-companion:answer`.
--
-- Pure subscriber, same doctrine as the orb: claude.luau owns the state, this panel
-- only renders it. state.watch() callbacks proved unreliable for bar widgets
-- (see pulse.luau) and a panel only lives while open anyway, so this polls on
-- second ticks and re-renders only when the payload actually changed — an
-- unconditional re-render each second would reset the scroll position mid-read.
local KEY = "claude.answer"
-- The panel surface is 460 logical px wide (plugin.toml); wrap text inside the
-- padding with room for the scrollbar.
local WRAP = 408
local PAD = 14
-- Fallback error accent, same as the pulse dot; normal body text keeps the
-- theme's default foreground by not setting a color at all.
local ERROR_RGB = "#FF4D1F"
local last = nil -- fingerprint of the last-rendered payload (skip no-op renders)
local function fingerprint(a)
if type(a) ~= "table" then return "" end
return tostring(a.at) .. "\1" .. tostring(a.q) .. "\1" .. tostring(a.error) .. "\1" .. tostring(a.text)
end
local function render(a)
local rows = { ui.label({ text = "Claude — quick ask", fontWeight = "bold" }) }
local body
if type(a) ~= "table" or type(a.text) ~= "string" or a.text == "" then
body = ui.label({
text = "No answers yet. Ask one from the launcher: /claude ? <question>",
maxWidth = WRAP,
opacity = 0.7,
})
else
if type(a.q) == "string" and a.q ~= "" then
local q = "? " .. a.q .. (type(a.at) == "string" and a.at ~= "" and (" · " .. a.at) or "")
rows[#rows + 1] = ui.label({ text = q, maxWidth = WRAP, maxLines = 3, opacity = 0.7 })
end
body = ui.label({
text = a.text,
maxWidth = WRAP,
color = a.error == true and ERROR_RGB or nil,
})
end
rows[#rows + 1] = ui.separator({})
rows[#rows + 1] = ui.scroll({ flexGrow = 1 }, { body })
-- flexGrow on the ROOT is load-bearing: the host sizes its root flex to the
-- panel, but this column is a child of it — without grow it takes its natural
-- (full-content) height, overflows, and the panel hard-clips the answer. Grown
-- to the panel height, the scroll child above is the one that gets bounded and
-- actually scrolls.
panel.render(ui.column({ padding = PAD, gap = 8, flexGrow = 1 }, rows))
end
-- Visibility flag for claude.luau: while the panel is open it live-refreshes the
-- answer (update below), so a toast for the same answer would be a redundant
-- second surface — and dismissing that toast clicks outside the panel, which
-- the click-shield turns into closing the panel too. claude.luau reads this flag
-- and skips the toast when the panel is already showing.
local OPEN_KEY = "claude.answer.open"
function onOpen(_context)
panel.setWantsSecondTicks(true) -- the host stops ticks on close, re-arms on reopen
noctalia.state.set(OPEN_KEY, true)
local a = noctalia.state.get(KEY)
last = fingerprint(a)
render(a)
end
function onClose()
noctalia.state.set(OPEN_KEY, false)
end
function update()
local a = noctalia.state.get(KEY)
local fp = fingerprint(a)
if fp ~= last then
last = fp
render(a)
end
end