I type a lot of prompts into Claude Code. Most of them are ordinary sentences like “check whether the deploy went through” or “fix the bug before deploying”.
That is a few hundred short English sentences a week that I write anyway. So I set up a hook that translates each one into the language I am trying to learn, French in my case, and prints it on the status line while Claude works.
Keeping it invisible to Claude took most of the work.
Translations don’t enter context
The obvious version of this is a line in CLAUDE.md asking Claude to open every reply with a translation of my message. Every translation then becomes conversation history. It sits in the context window until the session ends and gets re-sent with every request after it. Ask Claude to open each reply in another language and you have changed what it is doing.
So I needed the translation to reach my eyes without ever reaching a request, produced somewhere outside the session I am working in.
The status line is the right place
Most of the ways Claude Code puts text on screen feed the model.
I went through the hooks reference looking for a channel that surfaces text without adding context. The UserPromptSubmit hook fires the moment you hit enter, which is the right timing. Its output is built to reach the model. Anything the hook prints to stdout gets added to Claude’s context as plain text. The additionalContext field does the same thing by name. Even systemMessage, which sounds like a UI-only channel, is documented for this event as being added to Claude’s context as a system message.
Anything that shows up in your scrollback in this tool reaches the model, which rules out the whole transcript.
The status line is different. It is a script that Claude Code runs itself to render the bar above your input box. It receives session data on stdin and prints text that Claude Code draws. The output never goes into a request, so I can put something there for myself.
The status line only shows current state, so you see the translation of your latest message and nothing before it. Older ones scroll out of existence, which keeps my attention on one sentence.
How the pieces fit together
Four small Node scripts in ~/.claude/translate/, plus two keys in your settings file:
hook.jsruns onUserPromptSubmit. It decides whether the prompt is worth translating, writes it to a cache file, launches the translator as a detached process, and exits printing nothing.worker.jsis that detached process. It calls a translation endpoint and writes the result next to the English.filter.jsholds the rules for what counts as a sentence worth translating.statusline.jsreads the cache and prints the translation.
Everything here is Node, so the same four files work on Windows, macOS and Linux once you fix the paths.
The hook that says nothing
The hook must print nothing on stdout, because for this event stdout becomes context.
The translator runs detached for the same reason. UserPromptSubmit hooks are synchronous and hold your prompt until they finish, so the hook cannot sit waiting on a network call. It fires the worker and returns. A fraction of a second later the translation lands, and the status line picks it up on its next render.
Create ~/.claude/translate/hook.js:
// UserPromptSubmit hook: hands the prompt to a detached translator and exits silently.
// MUST print nothing on stdout. For UserPromptSubmit, Claude Code feeds plain stdout
// straight into the model's context, which is exactly what this feature must not do.
const fs = require("fs");
const path = require("path");
const { spawn } = require("child_process");
const { shouldTranslate } = require("./filter.js");
const DIR = __dirname;
const CACHE = path.join(DIR, "cache");
function main(raw) {
let data;
try {
data = JSON.parse(raw);
} catch {
return;
}
const session = String(data.session_id || "").replace(/[^A-Za-z0-9_-]/g, "");
const prompt = shouldTranslate(data.prompt);
if (!session || !prompt) return;
fs.mkdirSync(CACHE, { recursive: true });
fs.writeFileSync(path.join(CACHE, session + ".in"), prompt, "utf8");
spawn(process.execPath, [path.join(DIR, "worker.js"), session], {
detached: true,
stdio: "ignore",
windowsHide: true,
}).unref();
prune();
}
// Sessions never signal "done", so old cache entries are swept opportunistically.
function prune() {
const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
try {
for (const f of fs.readdirSync(CACHE)) {
const p = path.join(CACHE, f);
if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p);
}
} catch {}
}
let buf = "";
process.stdin.on("data", (c) => (buf += c));
process.stdin.on("end", () => {
try {
main(buf);
} catch {}
process.exit(0);
});
The translator
The detached process runs after the hook has already let your prompt through, so nothing is waiting on its latency.
It uses the endpoint behind Google Translate’s web widget, which needs no API key and answers in about 300ms.
Create ~/.claude/translate/worker.js:
// Detached translator. Runs after the hook has already returned, so its latency
// never delays prompt submission.
const fs = require("fs");
const path = require("path");
// The language to learn. Any code the endpoint accepts: es, de, ja, pt...
const TARGET_LANG = "fr";
const CACHE = path.join(__dirname, "cache");
const session = process.argv[2];
if (!session) process.exit(0);
const inFile = path.join(CACHE, session + ".in");
const outFile = path.join(CACHE, session + ".json");
(async () => {
let en;
try {
en = fs.readFileSync(inFile, "utf8");
} catch {
return;
}
if (!en.trim()) return;
const url =
"https://translate.googleapis.com/translate_a/single" +
"?client=gtx&sl=en&tl=" +
TARGET_LANG +
"&dt=t&q=" +
encodeURIComponent(en);
let fr;
try {
const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
if (!res.ok) return;
const data = await res.json();
// Response shape: [[[fr, en, ...], [fr, en, ...]], ...] one entry per sentence.
fr = (data[0] || [])
.map((seg) => seg && seg[0])
.filter(Boolean)
.join("")
.trim();
} catch {
return;
}
if (!fr) return;
// Written elsewhere then renamed so the status line never reads a half-written file.
const tmp = outFile + "." + process.pid + ".tmp";
fs.writeFileSync(tmp, JSON.stringify({ fr, en }), "utf8");
fs.renameSync(tmp, outFile);
})();
Cache files are keyed by session id, so two terminals running Claude Code at once never show each other’s translations.
Deciding what is worth translating
Some prompts are not worth translating. A 60 word instruction comes back as a wall of text you will skim past, and a pasted stack trace comes back as garbage.
Create ~/.claude/translate/filter.js:
// Decides whether a prompt is worth translating, and strips the parts that aren't.
// Returns cleaned prose, or null to leave the previous translation on screen.
const MAX_WORDS = 25;
const ACKS = new Set([
"ok",
"okay",
"k",
"yes",
"y",
"no",
"n",
"yep",
"nope",
"sure",
"go",
"go on",
"continue",
"thanks",
"thank you",
"ty",
"done",
"stop",
"wait",
"nice",
"perfect",
"great",
"good",
"cool",
"hmm",
"yeah",
"yup",
"do it",
"go ahead",
"proceed",
]);
// A word that reads as an identifier, path, URL, flag, or version rather than prose.
function isCodeish(w) {
return (
/[\\/_$#@{}<>|=;]/.test(w) ||
/\(\)/.test(w) ||
/\.[a-z]{1,4}$/i.test(w) || // auth.ts, README.md
/[a-z][A-Z]/.test(w) || // camelCase
/^\d+(\.\d+)*$/.test(w) || // 2.1.237
/^-{1,2}[a-z]/i.test(w) || // --flag
w.length > 24
);
}
function clean(prompt) {
return prompt
.replace(/```[\s\S]*?```/g, " ") // fenced code
.replace(/```[\s\S]*$/, " ") // unterminated fence
.replace(/`([^`\n]*)`/g, "$1") // unwrap inline code
.replace(/\[(Pasted text|Image|Screenshot)[^\]]*\]/gi, " ")
.replace(/\s+/g, " ")
.trim();
}
function shouldTranslate(prompt) {
const raw = (prompt || "").trim();
if (!raw) return null;
if (raw.startsWith("/") || raw.startsWith("!")) return null;
if (/^[A-Za-z]:[\\/]/.test(raw)) return null;
const text = clean(raw);
if (!text) return null;
if (ACKS.has(text.toLowerCase().replace(/[.!?,]+$/, ""))) return null;
const words = text.split(" ").filter(Boolean);
if (words.length > MAX_WORDS || text.length > 250) return null;
// Mostly symbols or digits reads as pasted output, not a sentence.
const nonSpace = text.replace(/\s/g, "");
const letters = (nonSpace.match(/[A-Za-zÀ-ɏ']/g) || []).length;
if (!nonSpace.length || letters / nonSpace.length < 0.7) return null;
if (words.filter(isCodeish).length / words.length > 0.25) return null;
return text;
}
module.exports = { shouldTranslate };
Two quirks in there came out of using it.
Inline code gets unwrapped. My first version stripped backticked spans, which turned “fix the bug in auth.ts before deploying” into “fix the bug in before deploying”, and you get a fluent translation of broken English. So the token stays in and the ratio check decides. A seven word sentence with one filename in it survives; anything denser than a quarter codeish words gets dropped.
A skipped prompt leaves the previous translation on screen. Type “ok” three times in a row and the last real sentence stays up.
Rendering it in the status line
Create ~/.claude/translate/statusline.js:
// Status line renderer. Claude Code runs this itself and never sends its output to
// the model, which is the whole point of putting the translation here.
const fs = require("fs");
const path = require("path");
const CACHE = path.join(__dirname, "cache");
const BLUE = "\x1b[38;5;110m";
const DIM = "\x1b[2m";
const OFF = "\x1b[0m";
const PENDING_MS = 12000;
const MAX_LINES = 4;
// Output is captured, not attached to the terminal, so tput and
// process.stdout.columns read nothing. Claude Code passes the width in COLUMNS.
const WIDTH = Math.max(20, (parseInt(process.env.COLUMNS, 10) || 80) - 2);
function wrap(s) {
const out = [];
let line = "";
const push = () => {
if (line) {
out.push(line);
line = "";
}
};
for (let word of s.split(/\s+/)) {
while (word.length > WIDTH) {
// a single token wider than the row
push();
out.push(word.slice(0, WIDTH));
word = word.slice(WIDTH);
}
if (line && (line + " " + word).length > WIDTH) push();
line = line ? line + " " + word : word;
}
push();
if (out.length > MAX_LINES) {
const kept = out.slice(0, MAX_LINES);
kept[MAX_LINES - 1] = kept[MAX_LINES - 1].slice(0, WIDTH - 1) + "…";
return kept;
}
return out;
}
function main(raw) {
let session = "";
try {
session = String(JSON.parse(raw).session_id || "").replace(
/[^A-Za-z0-9_-]/g,
"",
);
} catch {}
if (!session) return;
const inFile = path.join(CACHE, session + ".in");
const outFile = path.join(CACHE, session + ".json");
let cached = null,
outAt = 0;
try {
cached = JSON.parse(fs.readFileSync(outFile, "utf8"));
outAt = fs.statSync(outFile).mtimeMs;
} catch {}
let inAt = 0;
try {
inAt = fs.statSync(inFile).mtimeMs;
} catch {}
// A newer .in than .json means the current prompt is still being translated.
// Past PENDING_MS the worker is assumed dead (offline, endpoint down); fall back
// to the last good line rather than pinning a spinner there for the session.
if (inAt > outAt && Date.now() - inAt < PENDING_MS) {
process.stdout.write(DIM + "translating…" + OFF + "\n");
return;
}
if (!cached || !cached.fr) return;
for (const l of wrap(cached.fr)) process.stdout.write(BLUE + l + OFF + "\n");
}
let buf = "";
process.stdin.on("data", (c) => (buf += c));
process.stdin.on("end", () => {
try {
main(buf);
} catch {}
});
I got the width wrong at first. I hardcoded 100 characters, and on my 72 column terminal every line was packed to 100 and then cut off at 72, with the overflow never reaching a second row.
You cannot detect the width the usual way. Claude Code captures the script’s output rather than attaching it to the terminal, so tput cols and process.stdout.columns both come back empty. Claude Code sets COLUMNS before running the script, which requires v2.1.153 or later.
Turning it on
Wire both halves into ~/.claude/settings.json. Replace YOUR_USERNAME with your own, or use /Users/YOUR_USERNAME/ on macOS:
{
"hooks": {
"UserPromptSubmit": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "node \"C:/Users/YOUR_USERNAME/.claude/translate/hook.js\""
}
]
}
]
},
"statusLine": {
"type": "command",
"command": "node \"C:/Users/YOUR_USERNAME/.claude/translate/statusline.js\""
}
}
If your settings.json already has content, merge these two keys in rather than replacing the file. Hooks and the status line are both snapshotted at startup, so restart Claude Code once. After that, edits to statusline.js take effect on the next render without a restart.
One thing to expect: configuring a custom status line makes Claude Code drop most of the footer’s keyboard hints, including esc to interrupt.
Why not to use Claude for translating
A model translates better than a free endpoint, and it can add a note on the grammar in each sentence. I tried that and gave up on it once I measured the cost.
To use a Claude subscription rather than API credits, you have to shell out to claude -p in headless mode. Every one of those calls is a fresh Claude Code process. I measured a single six word sentence:
Headless claude -p | Free endpoint | |
|---|---|---|
| Latency | ~8.5 seconds | ~0.3 seconds |
| Tokens per prompt | ~29,000 | 0 |
The 29,000 is Claude Code’s own system prompt, tool definitions and your CLAUDE.md, loaded fresh on every call. Stripping out MCP servers and settings only brought it from 30,000 down to 29,200. At a hundred prompts a day, translating your own sentences would burn several million tokens a day of your rate limit while you are trying to use it for actual work.
There is also a trap worth knowing about if you try this: the headless session fires the same UserPromptSubmit hook, so it translates its own translation prompt, forever. You need a settings override or an environment guard to break the loop.
Once the filter only lets through prose of 25 words or less, the free endpoint holds up. It rendered “maybe we should only do it for short messages” as “Peut-être devrions-nous ajouter des choses comme ne le faire que pour les messages courts”, with the interrogative inversion intact.
If you want a model’s version, get an API key. A 25 word translation is roughly 100 input tokens, which is a fraction of a cent per prompt, and hundreds of times cheaper than the subscription path.
Switching to another language
TARGET_LANG at the top of worker.js is the only place the language is named. Set it to any code the endpoint accepts: es for Spanish, de for German, ja for Japanese, pt for Portuguese. Nothing else in the four files is specific to one language.
For a non-Latin script, check that your terminal font renders it before you commit to it, and note that the wrapping in statusline.js counts characters rather than display columns, so wide glyphs will wrap early.
Four files and two lines of config. It has run under every session since I set it up, and all it puts on screen is one coloured line above my input box.
If you like small Claude Code customizations that stay out of your way, I also wrote about adding notification sounds to Claude Code so you can walk away from a long task and get called back when it needs you.