fix: v2.5.9 — Vietnamese in the terminal on macOS, every slash command, the right Claude account
Issue #62: a tmux client whose LC_ALL/LC_CTYPE/LANG names no UTF-8 prints `_` for every non-ASCII character, and a macOS LaunchAgent starts the dashboard with no locale at all — Vietnamese text and Claude Code's logo came out as underscores in every browser terminal. - shell-path.js: ensureUtf8Locale() defaults LC_CTYPE (en_US.UTF-8 on macOS, C.UTF-8 elsewhere) at start, before any tmux call; an explicit LC_ALL and Windows are left alone - RunSetup.tsx: the `/` list now offers Claude Code's bundled skills (/code-review, /security-review, /simplify, /loop…) and newer built-ins, and every enabled plugin's commands and skills as /<plugin>:<name> - Claude login decided by the TOKEN's owner (GET /api/oauth/profile, cached per token), not by .claude.json: a running session can leave the credentials store holding one account's token under another account's name. The Profiles modal, its Check quota and the status-bar quota now show the account requests are really billed to and warn on the mismatch; a switch saves the outgoing token into its OWNER's profile (credentials only) instead of overwriting the profile .claude.json names; saving a profile while they disagree is refused (EMISMATCH); a saved profile holding another account's token reports EWRONGACCOUNT instead of that account's usage - Running Claude Code sessions follow a switch on their own (they re-read the credentials store when it changes), so the "close your sessions first" warning is gone
This commit is contained in:
@@ -121,6 +121,7 @@ function quotaMeta(q: ToolQuota, t: (k: string, o?: Record<string, unknown>) =>
|
||||
const QUOTA_ERROR_KEY: Record<string, string> = {
|
||||
ELIVEEXPIRED: "statusBar.quotaExpired",
|
||||
EPROFILEEXPIRED: "statusBar.quotaProfileExpired",
|
||||
EWRONGACCOUNT: "statusBar.quotaWrongAccount",
|
||||
};
|
||||
|
||||
/** One account: its name, sign-in line, then a row per window with its reset. */
|
||||
|
||||
@@ -47,12 +47,15 @@ import { isSubmitEnter } from "../../lib/keys";
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
source: "project" | "user" | "builtin";
|
||||
source: "project" | "user" | "plugin" | "builtin";
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Commands the CLI answers itself. They are typed into the pane like any
|
||||
// other text, so they do run — they are just not files anyone can list.
|
||||
// Commands the CLI answers itself, and the skills bundled inside it. They are
|
||||
// typed into the pane like any other text, so they do run — they are just not
|
||||
// files anyone can list.
|
||||
// ponytail: a hand-kept list, so it lags new Claude Code releases; no file or
|
||||
// API lists the built-ins, add names here as they ship.
|
||||
const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"help",
|
||||
"clear",
|
||||
@@ -62,6 +65,8 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"memory",
|
||||
"hooks",
|
||||
"cost",
|
||||
"usage",
|
||||
"context",
|
||||
"agents",
|
||||
"review",
|
||||
"permissions",
|
||||
@@ -69,14 +74,36 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
|
||||
"init",
|
||||
"mcp",
|
||||
"plugin",
|
||||
"reload-plugins",
|
||||
"skills",
|
||||
"output-style",
|
||||
"resume",
|
||||
"rewind",
|
||||
"add-dir",
|
||||
"doctor",
|
||||
"export",
|
||||
"login",
|
||||
"logout",
|
||||
"statusline",
|
||||
"theme",
|
||||
"fast",
|
||||
"exit",
|
||||
"code-review",
|
||||
"security-review",
|
||||
"simplify",
|
||||
"loop",
|
||||
"schedule",
|
||||
"fewer-permission-prompts",
|
||||
"update-config",
|
||||
"claude-api",
|
||||
"run",
|
||||
].map((name) => ({ name, source: "builtin" }));
|
||||
|
||||
const SOURCE_ORDER = { project: 0, user: 1, builtin: 2 } as const;
|
||||
const SOURCE_ORDER = { project: 0, user: 1, plugin: 2, builtin: 3 } as const;
|
||||
const SOURCE_TONE: Record<SlashCommand["source"], string> = {
|
||||
project: "text-status-success border-status-success/40",
|
||||
user: "text-accent border-accent/40",
|
||||
plugin: "text-accent border-accent/40",
|
||||
builtin: "text-fg-muted border-border",
|
||||
};
|
||||
|
||||
@@ -185,8 +212,26 @@ export function PromptEditor({
|
||||
Promise.all([
|
||||
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
|
||||
]).then(([c, sk]) => {
|
||||
if (live) setCommands([...c.items, ...sk.items].map(toCmd).concat(BUILTIN_SLASH_COMMANDS));
|
||||
api.ccConfig.plugins().catch(() => ({ plugins: [] })),
|
||||
]).then(([c, sk, pl]) => {
|
||||
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
|
||||
// installed at both user and project scope is listed once.
|
||||
const fromPlugins = pl.plugins
|
||||
.filter((p) => p.enabled !== false && p.contributes)
|
||||
.flatMap((p) =>
|
||||
[...p.contributes!.items.commands, ...p.contributes!.items.skills].map(
|
||||
(it): SlashCommand => ({
|
||||
name: `${p.name}:${it.name}`,
|
||||
source: "plugin",
|
||||
description: it.description || undefined,
|
||||
})
|
||||
)
|
||||
)
|
||||
.filter((cmd, i, all) => all.findIndex((o) => o.name === cmd.name) === i);
|
||||
if (live)
|
||||
setCommands(
|
||||
[...c.items, ...sk.items].map(toCmd).concat(fromPlugins, BUILTIN_SLASH_COMMANDS)
|
||||
);
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
|
||||
@@ -59,6 +59,25 @@ vi.mock("../../../lib/api", async (importOriginal) => {
|
||||
skills: r({
|
||||
items: [{ scope: "user", name: "review-pr", frontmatter: {}, preview: "Review a PR" }],
|
||||
}),
|
||||
plugins: r({
|
||||
plugins: [
|
||||
{
|
||||
name: "vercel",
|
||||
enabled: true,
|
||||
contributes: {
|
||||
items: {
|
||||
skills: [{ name: "deploy", description: "Deploy to Vercel" }],
|
||||
commands: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "off",
|
||||
enabled: false,
|
||||
contributes: { items: { skills: [{ name: "deployer" }], commands: [] } },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -96,6 +115,9 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
expect(options.map((o) => o.textContent)).toEqual([
|
||||
expect.stringContaining("/review"),
|
||||
expect.stringContaining("/review-pr"),
|
||||
// Claude Code's bundled skills, matched on a word of their name.
|
||||
expect.stringContaining("/code-review"),
|
||||
expect.stringContaining("/security-review"),
|
||||
]);
|
||||
fireEvent.keyDown(box(), { key: "ArrowDown" });
|
||||
// Read for the directory the session will start in, not the server's own.
|
||||
@@ -108,6 +130,13 @@ describe("PromptEditor — / and @ suggestions", () => {
|
||||
expect(spies.onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers enabled plugins' skills under their plugin's name", async () => {
|
||||
renderEditor("");
|
||||
fireEvent.change(box(), { target: { value: "/deplo" } });
|
||||
const options = await screen.findAllByRole("option");
|
||||
expect(options.map((o) => o.textContent)).toEqual([expect.stringContaining("/vercel:deploy")]);
|
||||
});
|
||||
|
||||
it("suggests files under the folder on @", async () => {
|
||||
vi.mocked(api.run.files).mockResolvedValue({ items: ["src/main.ts"] });
|
||||
// The prompt is controlled by the caller: put the cursor at the end of it.
|
||||
|
||||
@@ -263,7 +263,8 @@
|
||||
"title": "Claude Code profiles",
|
||||
"openButton": "Switch profile",
|
||||
"subtitle": "A profile is one Anthropic account - its own quota. Switching swaps only the login (credentials + account identity); skills, subagents, memory, plugins, marketplaces, MCP servers and settings all stay exactly as they are.",
|
||||
"warning": "Close your running Claude Code sessions before switching - a live session rewrites the config on exit and would put the old account back.",
|
||||
"warning": "Running Claude Code sessions follow a switch on their own: they pick up the new login within about 30 seconds.",
|
||||
"mismatch": "The login in use belongs to {{owner}}, but Claude Code's config names {{config}}. Requests are billed to {{owner}}. Switch to the account you want to fix this.",
|
||||
"activeLabel": "Logged in now",
|
||||
"noActive": "No Claude account is logged in on this machine.",
|
||||
"unsavedActive": "This account is not saved as a profile yet - save it, otherwise you cannot switch away without losing its credentials.",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"quotaRefreshing": "Refreshing…",
|
||||
"quotaExpired": "Token expired: use Claude Code once so it refreshes",
|
||||
"quotaFailed": "Could not read the quota",
|
||||
"quotaWrongAccount": "This sign-in belongs to another Claude account: log in as this account and save it again",
|
||||
"quotaActive": "in use",
|
||||
"quotaProfileExpired": "Sign-in expired: use Check quota in Claude Code → Profiles to refresh it",
|
||||
"quotaSignInExpires": "Sign-in expires in {{time}}",
|
||||
|
||||
@@ -258,7 +258,8 @@
|
||||
"title": "Profile Claude Code",
|
||||
"openButton": "Đổi profile",
|
||||
"subtitle": "Mỗi profile là một tài khoản Anthropic - quota riêng. Khi đổi, chỉ phần đăng nhập (credentials + danh tính tài khoản) được hoán đổi; skills, subagent, memory, plugin, marketplace, MCP server và settings vẫn giữ nguyên.",
|
||||
"warning": "Hãy đóng các phiên Claude Code đang chạy trước khi đổi - phiên đang chạy sẽ ghi lại config khi thoát và đưa tài khoản cũ quay về.",
|
||||
"warning": "Các phiên Claude Code đang chạy tự đi theo khi đổi: chúng nhận đăng nhập mới trong khoảng 30 giây.",
|
||||
"mismatch": "Đăng nhập đang dùng thuộc tài khoản {{owner}}, nhưng config của Claude Code lại ghi {{config}}. Mọi request đang tính vào {{owner}}. Bấm Chuyển sang đúng tài khoản bạn muốn để sửa.",
|
||||
"activeLabel": "Đang đăng nhập",
|
||||
"noActive": "Chưa có tài khoản Claude nào đăng nhập trên máy này.",
|
||||
"unsavedActive": "Tài khoản này chưa được lưu thành profile - hãy lưu lại, nếu không sẽ không thể đổi sang profile khác mà không mất credentials của nó.",
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
"quotaRefreshing": "Đang làm mới…",
|
||||
"quotaExpired": "Token đã hết hạn: dùng Claude Code một lần để nó tự làm mới",
|
||||
"quotaFailed": "Không đọc được quota",
|
||||
"quotaWrongAccount": "Lượt đăng nhập này thuộc tài khoản Claude khác: đăng nhập lại đúng tài khoản này rồi lưu lại",
|
||||
"quotaActive": "đang dùng",
|
||||
"quotaProfileExpired": "Đăng nhập đã hết hạn: bấm Kiểm tra quota trong Claude Code → Profiles để làm mới",
|
||||
"quotaSignInExpires": "Đăng nhập hết hạn sau {{time}}",
|
||||
|
||||
@@ -1770,7 +1770,13 @@ export const api = {
|
||||
* logged-in {@link CcAccountIdentity} (null when nobody is logged in).
|
||||
*/
|
||||
profiles: () =>
|
||||
request<{ items: CcProfile[]; active: CcAccountIdentity | null }>("/cc-config/profiles"),
|
||||
request<{
|
||||
items: CcProfile[];
|
||||
active: CcAccountIdentity | null;
|
||||
/** Set when the live token belongs to a different account than the
|
||||
* one `.claude.json` names; `active` is then the token's owner. */
|
||||
mismatch?: { configEmail: string | null } | null;
|
||||
}>("/cc-config/profiles"),
|
||||
/**
|
||||
* POST /api/cc-config/profiles - snapshot the logged-in account as a
|
||||
* profile. The identifier and initial label are derived from the account;
|
||||
|
||||
@@ -4763,6 +4763,8 @@ function ProfilesModal({
|
||||
const { t } = useTranslation("ccConfig");
|
||||
const [items, setItems] = useState<CcProfile[] | null>(null);
|
||||
const [active, setActive] = useState<CcAccountIdentity | null>(null);
|
||||
/** `.claude.json`'s account, when it is not the one the live token belongs to. */
|
||||
const [mismatchEmail, setMismatchEmail] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
/** `name` of the profile whose label is being edited inline, plus its draft. */
|
||||
@@ -4787,6 +4789,7 @@ function ProfilesModal({
|
||||
const r = await api.ccConfig.profiles();
|
||||
setItems(r.items);
|
||||
setActive(r.active);
|
||||
setMismatchEmail(r.mismatch ? r.mismatch.configEmail || "?" : null);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "unknown");
|
||||
@@ -4931,10 +4934,18 @@ function ProfilesModal({
|
||||
|
||||
<div className="px-4 py-3 border-b border-border space-y-2">
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.subtitle")}</p>
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>{t("profiles.warning")}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-fg-muted leading-relaxed">{t("profiles.warning")}</p>
|
||||
{mismatchEmail && (
|
||||
<p className="text-[11px] text-status-warning inline-flex items-start gap-1.5 leading-relaxed">
|
||||
<ShieldAlert className="w-3.5 h-3.5 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
{t("profiles.mismatch", {
|
||||
config: mismatchEmail,
|
||||
owner: active?.email || active?.accountUuid || "?",
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto p-4 space-y-3">
|
||||
|
||||
@@ -7233,7 +7233,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<p
|
||||
class="text-[10px] text-status-warning/90 mt-1"
|
||||
>
|
||||
UI build v2.5.8 (rebuild client to match)
|
||||
UI build v2.5.9 (rebuild client to match)
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user