claude companion: add English translations for user facing strings (#84)

This commit is contained in:
Jarred Robinson
2026-07-23 17:54:19 -04:00
committed by GitHub
parent 0a144f68fc
commit 8ee41fcfd6
6 changed files with 137 additions and 54 deletions
+5 -2
View File
@@ -12,6 +12,9 @@
local KEY = "claude.answer"
-- User-facing display strings live in translations/<lang>.json.
local function tr(key, args) return noctalia.tr(key, args) end
-- The panel surface is 460 logical px wide (plugin.toml); wrap text inside the
-- padding with room for the scrollbar.
local WRAP = 408
@@ -29,11 +32,11 @@ local function fingerprint(a)
end
local function render(a)
local rows = { ui.label({ text = "Claude — quick ask", fontWeight = "bold" }) }
local rows = { ui.label({ text = tr("answer.title"), 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>",
text = tr("answer.empty"),
maxWidth = WRAP,
opacity = 0.7,
})
+22 -15
View File
@@ -14,6 +14,12 @@ local function shq(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
-- User-facing display strings live in translations/<lang>.json; resolve them at
-- call time (the noctalia global is ready by then). Model-facing prompts
-- (ASK_NOTE, SYSTEM_NOTE below) are deliberately NOT translated — they are
-- instructions to the model, not UI, and rewording them can shift its behavior.
local function tr(key, args) return noctalia.tr(key, args) end
-- A double-quoted sh argument: unlike shq it lets the shell expand $VAR (we need
-- $HOME in the shim path so nothing user-specific is hardcoded). ONLY use this on
-- fixed, trusted strings — never on user input, which must go through shq().
@@ -150,7 +156,7 @@ end
-- 401 whenever no terminal session has run recently. Two layers, both fail-open:
-- a pre-flight that skips the launch when the token is positively expired, and
-- an error-text match that swaps the raw API error for the remedy.
local AUTH_REMEDY = "Claude login expired — start a Claude session in a terminal to refresh it, then re-ask."
-- (remedy text: tr("notify.auth_remedy"))
local CREDS_PATH = "~/.claude/.credentials.json"
@@ -220,14 +226,14 @@ local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
local function answer_row()
local a = noctalia.state.get("claude.answer")
if type(a) ~= "table" or type(a.text) ~= "string" or a.text == "" then return nil end
return { id = "answer", title = "Show last answer", subtitle = a.q, glyph = "message-2" }
return { id = "answer", title = tr("launcher.show_answer"), subtitle = a.q, glyph = "message-2" }
end
function onQuery(query)
local text = trim(query)
if text == "" then
local results = {
{ id = "continue", title = "Resume last Claude session", glyph = "robot" },
{ id = "continue", title = tr("launcher.continue"), glyph = "robot" },
}
results[#results + 1] = answer_row()
launcher.setResults(query, results)
@@ -237,19 +243,19 @@ function onQuery(query)
if ask ~= nil then
if ask == "" then
local results = {
{ id = "_askhint", title = "Ask Claude (one-shot)", subtitle = "Type a question after ?", glyph = "message-dots" },
{ id = "_askhint", title = tr("launcher.ask"), subtitle = tr("launcher.ask_hint"), glyph = "message-dots" },
}
results[#results + 1] = answer_row()
launcher.setResults(query, results)
else
launcher.setResults(query, {
{ id = "ask:" .. ask, title = "Ask Claude (one-shot)", subtitle = ask, glyph = "message-dots" },
{ id = "ask:" .. ask, title = tr("launcher.ask"), subtitle = ask, glyph = "message-dots" },
})
end
return
end
launcher.setResults(query, {
{ id = "task:" .. text, title = "Launch Claude Code", subtitle = text, glyph = "robot" },
{ id = "task:" .. text, title = tr("launcher.launch"), subtitle = text, glyph = "robot" },
})
end
@@ -278,8 +284,9 @@ function onActivate(id)
-- and the remedy needs a terminal anyway — so say so now and skip the
-- launch. No claude.state touch: no ask session ever starts.
if token_expired() then
publish_answer(ask, AUTH_REMEDY, true)
if not panel_showing() then noctalia.notifyError("Claude", AUTH_REMEDY) end
local remedy = tr("notify.auth_remedy")
publish_answer(ask, remedy, true)
if not panel_showing() then noctalia.notifyError(tr("notify.title"), remedy) end
return
end
set_state(EVENT.turn_start)
@@ -295,26 +302,26 @@ function onActivate(id)
elseif ev.kind == EVENT.turn_end then
local final = (ev.text and ev.text ~= "") and ev.text or acc
if final == "" then
noctalia.notify("Claude", "(no output)")
noctalia.notify(tr("notify.title"), tr("notify.no_output"))
else
publish_answer(ask, final, false)
if not panel_showing() then
local body, clipped = preview(final)
if clipped then body = body .. "\nFull answer: click the bar pulse" end
noctalia.notify("Claude", body)
if clipped then body = body .. "\n" .. tr("notify.full_answer_hint") end
noctalia.notify(tr("notify.title"), body)
end
end
elseif ev.kind == EVENT.error then
local final = (ev.text and ev.text ~= "") and ev.text or acc
if final == "" then final = "ask failed" end
if final == "" then final = tr("notify.ask_failed") end
-- a 401 that slipped past the pre-flight: deliver the remedy, not the
-- raw API error blob
if is_auth_error(final) then final = AUTH_REMEDY end
if is_auth_error(final) then final = tr("notify.auth_remedy") end
publish_answer(ask, final, true)
if not panel_showing() then
local body, clipped = preview(final)
if clipped then body = body .. "\nFull answer: click the bar pulse" end
noctalia.notifyError("Claude", body)
if clipped then body = body .. "\n" .. tr("notify.full_answer_hint") end
noctalia.notifyError(tr("notify.title"), body)
end
end
end)
+19 -12
View File
@@ -34,14 +34,21 @@
-- ~4s), opacity floors stay high so the icon glows down rather than blinking to dark,
-- and the size swing (below) is gentle. Urgency reads as a *faster, deeper* breath,
-- but even "needs you" stays a breath, never a flicker.
-- User-facing display strings live in translations/<lang>.json; the per-state
-- status word is keyed by state name ("state.orb.<state>") and resolved at render
-- time. cur.state is always a known state (the watch below maps anything else to
-- "idle"), so no fallback guard is needed here.
local function tr(key, args) return noctalia.tr(key, args) end
local function orb_word(state) return tr("state.orb." .. state) end
local VISUAL = {
idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80, word = "idle" },
turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00, word = "thinking" },
text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00, word = "responding" },
tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00, word = "running a tool" },
needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00, word = "needs you" },
turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95, word = "done — ready for you" },
error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00, word = "error" },
idle = { glyph = "robot", color = "secondary", period = 11.0, omin = 0.45, omax = 0.80 },
turn_start = { glyph = "brain", color = "primary", period = 7.5, omin = 0.55, omax = 1.00 },
text = { glyph = "message-dots", color = "primary", period = 7.0, omin = 0.58, omax = 1.00 },
tool_start = { glyph = "tool", color = "secondary", period = 7.5, omin = 0.55, omax = 1.00 },
needs_attention = { glyph = "bell-ringing", color = "error", period = 4.5, omin = 0.55, omax = 1.00 },
turn_end = { glyph = "bell", color = "primary", period = 8.0, omin = 0.55, omax = 0.95 },
error = { glyph = "alert-triangle", color = "error", period = 5.0, omin = 0.55, omax = 1.00 },
}
-- The orb IS the bar icon, swelling gently in and out — a desktop "bat signal". The
@@ -72,14 +79,14 @@ end
-- One compact status line under the orb. Kept short so it doesn't get clipped by a
-- narrow widget box: just the state word, or a session count, plus burn when there's
-- a single attributable session.
local function subtitle(v)
local function subtitle()
if cur.count > 1 then
return cur.count .. " sessions"
return tr("orb.sessions", { count = cur.count })
end
if cur.count == 1 and cur.model ~= "?" and (cur.tin + cur.tout) > 0 then
return v.word .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout)
return orb_word(cur.state) .. " · " .. kfmt(cur.tin) .. "/" .. kfmt(cur.tout)
end
return v.word
return orb_word(cur.state)
end
local function render()
@@ -98,7 +105,7 @@ local function render()
width = GLYPH_BOX, height = GLYPH_BOX,
}),
ui.label({
text = subtitle(v), fontSize = 12, color = "on_surface_variant",
text = subtitle(), fontSize = 12, color = "on_surface_variant",
maxWidth = 180, maxLines = 1, textAlign = "center",
}),
}))
+1 -1
View File
@@ -4,7 +4,7 @@
id = "lowcache/claude-companion"
name = "Claude Companion"
version = "1.0.0"
version = "1.0.1"
# Oldest plugin API level this plugin needs (the [[panel]] kind, ui controls,
# plugin IPC dispatch, the barWidget/onIpc surface). Bump when adopting a newer API.
plugin_api = 3
+30 -23
View File
@@ -87,17 +87,30 @@ local function resolve_accents()
ACCENTS.error = hex(m.mError) or ACCENTS.error
end
-- User-facing display strings live in translations/<lang>.json; resolve at call
-- time. Per-state tips ("state.tip.<state>") and compact words ("state.word.
-- <state>") are keyed by state name. KNOWN_STATE guards a bogus state from a
-- manual CLI poke so it falls back to raw text / idle exactly as the old tables did.
local function tr(key, args) return noctalia.tr(key, args) end
local KNOWN_STATE = {
idle = true, turn_start = true, text = true, tool_start = true,
needs_attention = true, turn_end = true, error = true,
}
local function state_tip(s) return tr("state.tip." .. (KNOWN_STATE[s] and s or "idle")) end
local function state_word(s) return KNOWN_STATE[s] and tr("state.word." .. s) or s end
-- ── state map ────────────────────────────────────────────────────────────────
-- `color` names an ACCENTS role; `period` is the breath cycle in seconds —
-- urgency reads as tempo (needs-you breathes fast, idle slow).
-- urgency reads as tempo (needs-you breathes fast, idle slow). Tooltip text is
-- resolved from translations by state name (state_tip/state_word above).
local VISUAL = {
idle = { glyph = "robot", color = "secondary", tip = "Claude: idle", period = 8.0 },
turn_start = { glyph = "brain", color = "primary", tip = "Claude: thinking", period = 5.0 },
text = { glyph = "message-dots", color = "primary", tip = "Claude: responding", period = 4.5 },
tool_start = { glyph = "tool", color = "secondary", tip = "Claude: running a tool", period = 5.0 },
needs_attention = { glyph = "bell-ringing", color = "error", tip = "Claude needs you", period = 3.0 },
turn_end = { glyph = "bell", color = "primary", tip = "Claude: done — ready for you", period = 5.5 },
error = { glyph = "alert-triangle", color = "error", tip = "Claude: error", period = 3.5 },
idle = { glyph = "robot", color = "secondary", period = 8.0 },
turn_start = { glyph = "brain", color = "primary", period = 5.0 },
text = { glyph = "message-dots", color = "primary", period = 4.5 },
tool_start = { glyph = "tool", color = "secondary", period = 5.0 },
needs_attention = { glyph = "bell-ringing", color = "error", period = 3.0 },
turn_end = { glyph = "bell", color = "primary", period = 5.5 },
error = { glyph = "alert-triangle", color = "error", period = 3.5 },
}
-- With several sessions in different states, the bar shows the most urgent one:
@@ -106,12 +119,6 @@ local STATE_PRIO = {
needs_attention = 6, error = 5, tool_start = 4,
turn_start = 3, text = 3, turn_end = 2, idle = 1,
}
-- Compact per-session words for the multi-session tooltip.
local STATE_WORD = {
idle = "idle", turn_start = "thinking", text = "responding",
tool_start = "tool", needs_attention = "needs you",
turn_end = "done", error = "error",
}
-- sid -> { sid, state, model, tin, tout, cr, seq }. `seq` is a monotonic counter
-- (no os.time dependency in the widget sandbox) used to order by recency.
@@ -185,8 +192,8 @@ local function has_burn(s)
end
local function burn_line(s)
local l = string.format("%s · %s in / %s out", s.model, kfmt(s.tin), kfmt(s.tout))
if (s.cr or 0) > 0 then l = l .. " · " .. kfmt(s.cr) .. " cached" end
local l = tr("pulse.burn", { model = s.model, tin = kfmt(s.tin), tout = kfmt(s.tout) })
if (s.cr or 0) > 0 then l = l .. " · " .. tr("pulse.cached", { n = kfmt(s.cr) }) end
return l
end
@@ -217,16 +224,16 @@ local function render()
local tip
if count == 0 then
tip = VISUAL.idle.tip
tip = state_tip("idle")
elseif count == 1 then
local s = arr[1]
local sv = VISUAL[s.state] or VISUAL.idle
tip = has_burn(s) and (sv.tip .. "\n" .. burn_line(s)) or sv.tip
local base = state_tip(s.state)
tip = has_burn(s) and (base .. "\n" .. burn_line(s)) or base
else
local lines = { string.format("Claude — %d sessions", count) }
local lines = { tr("pulse.sessions_header", { count = count }) }
local Tin, Tout = 0, 0
for _, s in ipairs(arr) do
local line = s.sid .. " · " .. (STATE_WORD[s.state] or s.state)
local line = s.sid .. " · " .. state_word(s.state)
if has_burn(s) then
line = line .. " · " .. s.model .. " " .. kfmt(s.tin) .. "/" .. kfmt(s.tout)
Tin = Tin + s.tin; Tout = Tout + s.tout
@@ -234,7 +241,7 @@ local function render()
lines[#lines + 1] = line
end
if (Tin + Tout) > 0 then
lines[#lines + 1] = string.format("Σ %s in / %s out", kfmt(Tin), kfmt(Tout))
lines[#lines + 1] = tr("pulse.total", { tin = kfmt(Tin), tout = kfmt(Tout) })
end
tip = table.concat(lines, "\n")
end
@@ -323,7 +330,7 @@ end
-- appeared dead in live testing). Dismiss is click-outside/Esc, panel idiom.
function onClick()
if not noctalia.runAsync("noctalia msg panel-open 'lowcache/claude-companion:answer'") then
noctalia.notifyError("pulse", "could not launch the answer panel")
noctalia.notifyError(tr("pulse.title"), tr("pulse.panel_launch_failed"))
end
end
+60 -1
View File
@@ -1 +1,60 @@
{}
{
"notify": {
"title": "Claude",
"no_output": "(no output)",
"ask_failed": "ask failed",
"full_answer_hint": "Full answer: click the bar pulse",
"auth_remedy": "Claude login expired — start a Claude session in a terminal to refresh it, then re-ask."
},
"launcher": {
"continue": "Resume last Claude session",
"show_answer": "Show last answer",
"ask": "Ask Claude (one-shot)",
"ask_hint": "Type a question after ?",
"launch": "Launch Claude Code"
},
"answer": {
"title": "Claude — quick ask",
"empty": "No answers yet. Ask one from the launcher: /claude ? <question>"
},
"pulse": {
"title": "pulse",
"panel_launch_failed": "could not launch the answer panel",
"sessions_header": "Claude — {count} sessions",
"burn": "{model} · {tin} in / {tout} out",
"cached": "{n} cached",
"total": "Σ {tin} in / {tout} out"
},
"orb": {
"sessions": "{count} sessions"
},
"state": {
"tip": {
"idle": "Claude: idle",
"turn_start": "Claude: thinking",
"text": "Claude: responding",
"tool_start": "Claude: running a tool",
"needs_attention": "Claude needs you",
"turn_end": "Claude: done — ready for you",
"error": "Claude: error"
},
"word": {
"idle": "idle",
"turn_start": "thinking",
"text": "responding",
"tool_start": "tool",
"needs_attention": "needs you",
"turn_end": "done",
"error": "error"
},
"orb": {
"idle": "idle",
"turn_start": "thinking",
"text": "responding",
"tool_start": "running a tool",
"needs_attention": "needs you",
"turn_end": "done — ready for you",
"error": "error"
}
}
}