From 8ee41fcfd6e3f4434c0d73cea0a2b06325d52d2b Mon Sep 17 00:00:00 2001 From: Jarred Robinson Date: Thu, 23 Jul 2026 16:54:19 -0500 Subject: [PATCH] claude companion: add English translations for user facing strings (#84) --- claude-companion/answer.luau | 7 ++- claude-companion/claude.luau | 37 +++++++++------- claude-companion/orb.luau | 31 ++++++++------ claude-companion/plugin.toml | 2 +- claude-companion/pulse.luau | 53 +++++++++++++---------- claude-companion/translations/en.json | 61 ++++++++++++++++++++++++++- 6 files changed, 137 insertions(+), 54 deletions(-) diff --git a/claude-companion/answer.luau b/claude-companion/answer.luau index d3cce6d..bf65002 100644 --- a/claude-companion/answer.luau +++ b/claude-companion/answer.luau @@ -12,6 +12,9 @@ local KEY = "claude.answer" +-- User-facing display strings live in translations/.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 ? ", + text = tr("answer.empty"), maxWidth = WRAP, opacity = 0.7, }) diff --git a/claude-companion/claude.luau b/claude-companion/claude.luau index 008cbdc..7c27292 100644 --- a/claude-companion/claude.luau +++ b/claude-companion/claude.luau @@ -14,6 +14,12 @@ local function shq(s) return "'" .. tostring(s):gsub("'", "'\\''") .. "'" end +-- User-facing display strings live in translations/.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) diff --git a/claude-companion/orb.luau b/claude-companion/orb.luau index 94d06e1..d62ac66 100644 --- a/claude-companion/orb.luau +++ b/claude-companion/orb.luau @@ -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/.json; the per-state +-- status word is keyed by state name ("state.orb.") 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", }), })) diff --git a/claude-companion/plugin.toml b/claude-companion/plugin.toml index e7b6c7a..1206842 100644 --- a/claude-companion/plugin.toml +++ b/claude-companion/plugin.toml @@ -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 diff --git a/claude-companion/pulse.luau b/claude-companion/pulse.luau index 871c870..95785dc 100644 --- a/claude-companion/pulse.luau +++ b/claude-companion/pulse.luau @@ -87,17 +87,30 @@ local function resolve_accents() ACCENTS.error = hex(m.mError) or ACCENTS.error end +-- User-facing display strings live in translations/.json; resolve at call +-- time. Per-state tips ("state.tip.") and compact words ("state.word. +-- ") 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 diff --git a/claude-companion/translations/en.json b/claude-companion/translations/en.json index 0967ef4..115b5cb 100644 --- a/claude-companion/translations/en.json +++ b/claude-companion/translations/en.json @@ -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 ? " + }, + "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" + } + } +}