Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 20 additions & 19 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { defaultCountTokens, parseBlockIdArg, collectBlockContent, formatRanges
import { getSystemPromptText } from "./compat.js";
import { getDelegateUsage } from "./delegate-tool.js";
import { formatCompactTokens } from "./footer-status.js";
import { t } from "./i18n.js";

declare const CURRENT_VERSION: string;

Expand All @@ -14,56 +15,56 @@ export function makeCommands(runtime: AcpRuntime): Array<{ name: string; options
{
name: "acp",
options: {
description: "Show ACP context usage, token breakdown, and compression status.",
description: t("acp.description"),
handler: async (_args, ctx) => ctx.ui.notify(await statusReport(runtime, ctx)),
},
},
{
name: "acp-status",
options: {
description: "Detailed ACP status (block tiers, token breakdown, delegate usage).",
description: t("acp-status.description"),
handler: async (_args, ctx) => ctx.ui.notify(await statusReport(runtime, ctx)),
},
},
{
name: "acp-decompress",
options: {
description: "Restore a compressed block's content (shown here, block stays folded). Usage: /acp-decompress b3",
description: t("acp-decompress.description"),
handler: async (args, ctx) => {
const blockId = parseBlockIdArg(args);
if (!blockId) {
ctx.ui.notify('Usage: /acp-decompress <blockId> (e.g. "b3")');
ctx.ui.notify(t("decompress.usage"));
return;
}
const { state, coreMessages } = await runtime.stateFor(ctx);
const block = state.blocks.find((b) => b.blockId === blockId);
if (!block) {
ctx.ui.notify(`Block ${blockId} not found.`);
ctx.ui.notify(t("decompress.not-found", { id: blockId }));
return;
}
const { text, count } = collectBlockContent(state, block, coreMessages, { full: false });
if (count === 0) {
ctx.ui.notify(`Block ${blockId} has no restorable message content.`);
ctx.ui.notify(t("decompress.empty", { id: blockId }));
return;
}
ctx.ui.notify(`Block ${blockId} (${count} items):\n\n${text}`);
ctx.ui.notify(t("decompress.result", { id: blockId, count, text }));
},
},
},
{
name: "acp-search",
options: {
description: "Search compressed block summaries. Usage: /acp-search auth token",
description: t("acp-search.description"),
handler: async (args, ctx) => {
const query = args.trim();
if (!query) {
ctx.ui.notify("Usage: /acp-search <query>");
ctx.ui.notify(t("search.usage"));
return;
}
const { state } = await runtime.stateFor(ctx);
const hits = runtime.core.search(query, state);
if (hits.length === 0) {
ctx.ui.notify("No matching blocks.");
ctx.ui.notify(t("search.no-match"));
return;
}
const lines = hits.map((b) => `[${b.blockId}] (t${b.tier}) ${b.topic ?? ""}`.trim());
Expand Down Expand Up @@ -121,16 +122,16 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext):
lines.push("╰─────────────────────────────────────────────╯");
if (versionStr) lines.push(versionStr);
lines.push("");
lines.push(`Context: ${displayPct}% (${fmtTokens(displayTotal)} / ${fmtTokens(limit)})`);
lines.push(t("context", { pct: displayPct, used: fmtTokens(displayTotal), limit: fmtTokens(limit) }));

if (nudge && bd) {
const growth = bd.growth;
if (growth > 0 && displayTotal > 0) {
lines.push(`Growth: +${fmtTokens(growth)} since last nudge`);
lines.push(t("growth", { growth: fmtTokens(growth) }));
}
if (displayTotal > 0) {
lines.push("");
lines.push("Token Breakdown:");
lines.push(t("breakdown"));

const categories: Array<{ label: string; value: number }> = [
{ label: "Tool", value: bd.tool },
Expand All @@ -155,9 +156,9 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext):
if (nudge) {
if (nudge.shouldInject) {
const tierInfo = nudge.tier ? ` [T${nudge.tier} distillation]` : "";
lines.push(`Nudge: ACTIVE${tierInfo} — ${nudge.reason}`);
lines.push(t("nudge.active", { tier: tierInfo, reason: nudge.reason }));
} else {
lines.push(`Nudge: idle — ${nudge.reason}`);
lines.push(t("nudge.idle", { reason: nudge.reason }));
}
}

Expand All @@ -170,7 +171,7 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext):

if (activeBlocksList.length > 0) {
lines.push("");
lines.push(`Blocks: ${activeBlocksList.length} active / ${totalBlocksList.length} total (${fmtTokens(state.stats.tokensCompressed)} tokens compressed)`);
lines.push(t("blocks.active", { active: activeBlocksList.length, total: totalBlocksList.length, tokens: fmtTokens(state.stats.tokensCompressed) }));
for (const b of activeBlocksList) {
const topic = b.topic ? `: ${b.topic}` : "";
const summaryTok = defaultCountTokens(b.summary || "");
Expand All @@ -179,10 +180,10 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext):
}
} else if (totalBlocksList.length > 0) {
lines.push("");
lines.push(`Blocks: 0 active / ${totalBlocksList.length} total (${fmtTokens(state.stats.tokensCompressed)} tokens compressed)`);
lines.push(t("blocks.active", { active: 0, total: totalBlocksList.length, tokens: fmtTokens(state.stats.tokensCompressed) }));
} else {
lines.push("");
lines.push("Blocks: none (nothing compressed yet)");
lines.push(t("blocks.none"));
}

lines.push("");
Expand All @@ -195,7 +196,7 @@ async function statusReport(runtime: AcpRuntime, ctx: ExtensionCommandContext):
lines.push(`Tokens: ${delegateUsage.input.toLocaleString()} in, ${delegateUsage.output.toLocaleString()} out (${delegateUsage.totalTokens.toLocaleString()} total)${costStr}`);
}
lines.push("");
lines.push("Tag visibility: tags injected to LLM only (deep copy), not persisted in session, not shown in terminal.");
lines.push(t("tag-visibility"));

return lines.join("\n");
}
3 changes: 3 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ export interface AdapterConfig {
* replacing the kernel's tuned compression rules may reduce summary quality
* (lost paths/signatures/decisions → worse retrieval). */
acknowledgePromptsRisk?: boolean;
/** 界面语言(slash 命令输出 / 状态报告):"zh" | "en"。缺省按系统 LANG 检测。
* 仅影响展示层文本;面向模型的提示(system prompt / nudge)始终为英文。 */
language?: "zh" | "en";
coreOverrides?: Partial<Config>;
}

Expand Down
84 changes: 84 additions & 0 deletions src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Minimal i18n for user-facing slash-command output (descriptions, /acp and
* /acp-decompress / /acp-search results, the /acp status report).
*
* Machine-facing text (tool descriptions, system prompts, nudge reports
* injected to the model) is deliberately NOT localized here — translating
* model-facing instructions risks degrading compression/nudge quality.
*
* `language` is resolved once per process: an explicit acp.json override
* wins, otherwise the system LANG/LC_ALL env is inspected. The locale is a
* process-global cache so command handlers and descriptions stay consistent
* within a session.
*/
export type Locale = "en" | "zh";

export function detectLocale(): Locale {
const raw = process.env.LC_ALL ?? process.env.LANG ?? "";
return /^zh/i.test(raw) ? "zh" : "en";
}

const zh = {
"acp.description": "显示 ACP 上下文占用、Token 构成与压缩状态。",
"acp-status.description": "ACP 详细状态(压缩块层级、Token 构成、委派用量)。",
"acp-decompress.description": "恢复压缩块内容(显示在会话中,块保持折叠)。用法: /acp-decompress b3",
"acp-search.description": "搜索压缩块摘要。用法: /acp-search auth token",
"decompress.usage": "用法: /acp-decompress <blockId>(例如 \"b3\")",
"decompress.not-found": "块 {id} 未找到。",
"decompress.empty": "块 {id} 没有可恢复的消息内容。",
"decompress.result": "块 {id}({count} 条消息):\n\n{text}",
"search.usage": "用法: /acp-search <查询词>",
"search.no-match": "没有匹配的块。",
"context": "上下文: {pct}% ({used} / {limit})",
"growth": "较上次提示增长: +{growth}",
"breakdown": "Token 构成:",
"nudge.active": "提示: 活跃{tier} — {reason}",
"nudge.idle": "提示: 空闲 — {reason}",
"blocks.active": "压缩块: {active} 活跃 / {total} 总计(已压缩 {tokens} tokens)",
"blocks.none": "压缩块: 无(尚未压缩任何内容)",
"tag-visibility": "标签可见性: 仅注入 LLM(深拷贝),不写入会话,不在终端显示。",
} as const;

const en: Record<keyof typeof zh, string> = {
"acp.description": "Show ACP context usage, token breakdown, and compression status.",
"acp-status.description": "Detailed ACP status (block tiers, token breakdown, delegate usage).",
"acp-decompress.description": "Restore a compressed block's content (shown here, block stays folded). Usage: /acp-decompress b3",
"acp-search.description": "Search compressed block summaries. Usage: /acp-search auth token",
"decompress.usage": 'Usage: /acp-decompress <blockId> (e.g. "b3")',
"decompress.not-found": "Block {id} not found.",
"decompress.empty": "Block {id} has no restorable message content.",
"decompress.result": "Block {id} ({count} items):\n\n{text}",
"search.usage": "Usage: /acp-search <query>",
"search.no-match": "No matching blocks.",
"context": "Context: {pct}% ({used} / {limit})",
"growth": "Growth: +{growth} since last nudge",
"breakdown": "Token Breakdown:",
"nudge.active": "Nudge: ACTIVE{tier} — {reason}",
"nudge.idle": "Nudge: idle — {reason}",
"blocks.active": "Blocks: {active} active / {total} total ({tokens} tokens compressed)",
"blocks.none": "Blocks: none (nothing compressed yet)",
"tag-visibility": "Tag visibility: tags injected to LLM only (deep copy), not persisted in session, not shown in terminal.",
};

let cached: Locale | null = null;

/** Resolve locale once per process (LANG/LC_ALL don't change mid-session). */
export function locale(): Locale {
if (!cached) cached = detectLocale();
return cached;
}

/** Override locale from user config (acp.json "language"); null resets to LANG detection. */
export function setLocale(lang: string | undefined): void {
cached = lang === "zh" || lang === "en" ? lang : null;
}

/** Translate a key, substituting {name} placeholders. Falls back to English. */
export function t(key: keyof typeof zh, params?: Record<string, string | number>): string {
const table: Record<string, string> = locale() === "zh" ? zh : en;
let out = table[key] ?? en[key];
if (params) {
for (const [name, value] of Object.entries(params)) out = out.replaceAll(`{${name}}`, String(value));
}
return out;
}
15 changes: 12 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { makeSearchTool } from "./search-tool.js";
import { makeStatusTool } from "./status-tool.js";
import { makeDelegateTool, makeDelegateWaitTool, makeDelegateCancelTool, runningRunsSnapshot, resetDelegateUsage, setDelegateDisplayUsage } from "./delegate-tool.js";
import { makeCommands } from "./commands.js";
import { setLocale } from "./i18n.js";
import { coreOutToAgentMessages } from "./messages.js";
import { buildAcpSystemPrompt, ACP_DELEGATE_PROMPT } from "./system-prompt.js";
import { delegateStatusWidget } from "./fleet-widget.js";
Expand Down Expand Up @@ -41,12 +42,18 @@ export function createAcpExtension(adapter: AdapterConfig = {}): ExtensionFactor
pi.registerTool(makeDecompressTool(runtime));
pi.registerTool(makeSearchTool(runtime));
pi.registerTool(makeStatusTool(runtime));
for (const { name, options } of makeCommands(runtime)) {
pi.registerCommand(name, options);
}
registerCommands(pi, runtime);
};
}

/** Register (or re-register) the ACP slash commands. Re-callable so command
* descriptions pick up the configured locale after acp.json is loaded. */
function registerCommands(pi: ExtensionAPI, runtime: AcpRuntime): void {
for (const { name, options } of makeCommands(runtime)) {
pi.registerCommand(name, options);
}
}

export default createAcpExtension();

// ACP owns compression; cancel Pi's built-in auto-compaction entirely (mirrors
Expand All @@ -72,6 +79,8 @@ function wireSessionLifecycle(pi: ExtensionAPI, runtime: AcpRuntime): void {
runtime.setAdapter(applyUserConfig(runtime.adapter, user));
setDelegateDisplayUsage(resolveDelegate(runtime.adapter).displayUsage);
if (runtime.adapter.debug !== undefined) setDebugEnabled(runtime.adapter.debug);
setLocale(runtime.adapter.language);
registerCommands(pi, runtime);
} catch (e) {
logThrow("config", e, { sid, phase: "session_start" });
}
Expand Down
3 changes: 2 additions & 1 deletion src/user-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface UserAcpConfig {
displayUsage?: "merged" | "separate";
prompts?: Partial<Prompts>;
acknowledgePromptsRisk?: boolean;
language?: "zh" | "en";
}

/** Read global + project acp.json, project overrides global. Returns {} on any
Expand Down Expand Up @@ -54,7 +55,7 @@ const KNOWN = new Set([
"debug", "autoUpdate", "modelContextLimit",
"toolBashDefaultTimeout", "toolOutputMaxBytes",
"delegate", "compress", "displayUsage",
"prompts", "acknowledgePromptsRisk",
"prompts", "acknowledgePromptsRisk", "language",
]);

function pickKnown(parsed: Record<string, unknown>): UserAcpConfig {
Expand Down
57 changes: 57 additions & 0 deletions tests/i18n.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { t, setLocale, locale, detectLocale } from "../src/i18n.js";

test("detectLocale returns zh when LANG starts with zh", () => {
const old = { ...process.env };
try {
process.env.LANG = "zh_CN.UTF-8";
delete process.env.LC_ALL;
assert.equal(detectLocale(), "zh");
process.env.LANG = "en_US.UTF-8";
assert.equal(detectLocale(), "en");
} finally {
process.env = old;
}
});

test("LC_ALL takes precedence over LANG", () => {
const old = { ...process.env };
try {
process.env.LANG = "en_US.UTF-8";
process.env.LC_ALL = "zh_TW.UTF-8";
assert.equal(detectLocale(), "zh");
} finally {
process.env = old;
}
});

test("setLocale override wins and resets to detection on undefined", () => {
setLocale("zh");
assert.equal(locale(), "zh");
assert.match(t("blocks.none"), /尚未压缩/);
setLocale(undefined);
assert.ok(locale() === "zh" || locale() === "en");
});

test("t substitutes {name} placeholders with strings and numbers", () => {
setLocale("en");
assert.equal(
t("context", { pct: 42, used: "10K", limit: "200K" }),
"Context: 42% (10K / 200K)",
);
setLocale("zh");
assert.equal(
t("context", { pct: 42, used: "10K", limit: "200K" }),
"上下文: 42% (10K / 200K)",
);
});

test("zh and en tables differ for a representative key (translations present)", () => {
setLocale("zh");
const zhBlock = t("tag-visibility");
setLocale("en");
const enBlock = t("tag-visibility");
assert.ok(zhBlock.length > 0 && enBlock.length > 0);
assert.notEqual(zhBlock, enBlock);
});
Loading