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
10 changes: 4 additions & 6 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
loadBaseUrl,
loadReasoningEffort,
loadTheme,
loadThemeEnv,
markEditModeHintShown,
markMouseClipboardHintShown,
mouseClipboardHintShown,
Expand Down Expand Up @@ -393,7 +394,7 @@ export function App(props: AppProps): React.ReactElement {
[props.session],
);
const [themeName, setThemeName] = React.useState<ThemeName>(() =>
resolveThemePreference(loadTheme(), process.env.REASONIX_THEME),
resolveThemePreference(loadTheme(), loadThemeEnv()),
);
const statusBar = React.useMemo((): StatusBarConfig => {
const cfg = readConfig().statusBar ?? {};
Expand Down Expand Up @@ -2535,7 +2536,7 @@ function AppInner({
const handleQQThemePick = useCallback(
(target: ThemeChoice): string => {
saveTheme(target);
const active = resolveThemePreference(target, process.env.REASONIX_THEME);
const active = resolveThemePreference(target, loadThemeEnv());
setThemeName(active);
return `theme saved: ${target}\nactive now: ${active}`;
},
Expand Down Expand Up @@ -4280,10 +4281,7 @@ function AppInner({
setPendingThemePicker(false);
if (outcome.kind === "quit") return;
saveTheme(outcome.value);
const active = resolveThemePreference(
outcome.value,
process.env.REASONIX_THEME,
);
const active = resolveThemePreference(outcome.value, loadThemeEnv());
setThemeName(active);
log.pushInfo(`theme saved: ${outcome.value}\n active now: ${active}`);
}}
Expand Down
24 changes: 21 additions & 3 deletions src/cli/ui/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export interface SingleSelectProps<V extends string> {
footer?: string;
/** Render item hints on the same row as the label instead of a second row. */
inlineHints?: boolean;
/** Render labels, hints, and footer with themed colors instead of terminal-dim text. */
vividHints?: boolean;
/** Ignore matching keystrokes so an enclosing component can own them. */
ignoreKey?: (ev: KeyEvent) => boolean;
}
Expand All @@ -38,6 +40,7 @@ export function SingleSelect<V extends string>({
onCancel,
footer,
inlineHints = false,
vividHints = false,
ignoreKey,
}: SingleSelectProps<V>) {
const color = useColor();
Expand Down Expand Up @@ -74,11 +77,14 @@ export function SingleSelect<V extends string>({
marker={i === index ? "▸" : " "}
color={color}
inlineHint={inlineHints}
vividHint={vividHints}
/>
))}
{footer ? (
<Box marginTop={1}>
<Text dimColor>{footer}</Text>
<Text color={vividHints ? color.info : undefined} dimColor={!vividHints}>
{footer}
</Text>
</Box>
) : null}
</Box>
Expand Down Expand Up @@ -168,22 +174,34 @@ function SelectRow<V extends string>({
marker,
color,
inlineHint = false,
vividHint = false,
}: {
item: SelectItem<V>;
active: boolean;
marker: string;
color: UiColor;
inlineHint?: boolean;
vividHint?: boolean;
}) {
const rowColor = item.disabled ? color.info : active ? color.primary : undefined;
const rowColor = item.disabled
? color.info
: active
? color.primary
: vividHint
? color.info
: undefined;
const labelText = `${marker} ${item.label}`;
if (inlineHint) {
return (
<Box flexDirection="row" flexWrap="nowrap" minHeight={1}>
<Text color={rowColor} bold={active} dimColor={item.disabled} wrap="truncate">
{labelText}
</Text>
{item.hint ? <Text dimColor wrap="truncate">{` ${item.hint}`}</Text> : null}
{item.hint ? (
<Text color={vividHint ? color.accent : undefined} dimColor={!vividHint} wrap="truncate">
{` ${item.hint}`}
</Text>
) : null}
</Box>
);
}
Expand Down
30 changes: 29 additions & 1 deletion src/cli/ui/ThemePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function ThemePicker({
const choices: ThemeChoice[] = ["auto", ...listThemeNames()];
const items: SelectItem<ThemeChoice>[] = choices.map((value) => ({
value,
label: value,
label: themeChoiceLabel(value),
hint: describeTheme(value, currentPreference, activeTheme),
}));

Expand All @@ -33,11 +33,39 @@ export function ThemePicker({
onSubmit={(value) => onChoose({ kind: "select", value })}
onCancel={() => onChoose({ kind: "quit" })}
footer={t("themePicker.footer")}
inlineHints
vividHints
/>
</Box>
);
}

function themeChoiceLabel(value: ThemeChoice): string {
const label = t(themeLabelKey(value));
return label === value ? value : `${value} - ${label}`;
}

function themeLabelKey(value: ThemeChoice): string {
switch (value) {
case "auto":
return "themePicker.autoLabel";
case "default":
return "themePicker.defaultLabel";
case "dark":
return "themePicker.darkLabel";
case "light":
return "themePicker.lightLabel";
case "tokyo-night":
return "themePicker.tokyoNightLabel";
case "github-dark":
return "themePicker.githubDarkLabel";
case "github-light":
return "themePicker.githubLightLabel";
case "high-contrast":
return "themePicker.highContrastLabel";
}
}

function describeTheme(
value: ThemeChoice,
currentPreference: ThemeChoice,
Expand Down
5 changes: 3 additions & 2 deletions src/cli/ui/Wizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
isPlausibleKey,
loadBaseUrl,
loadTheme,
loadThemeEnv,
readConfig,
redactKey,
resolveThemePreference,
Expand Down Expand Up @@ -94,13 +95,13 @@ export function Wizard({
useEffect(() => onLanguageChange(() => setLanguageVersion((v) => v + 1)), []);

const [previewTheme, setPreviewTheme] = useState<ThemeName>(() =>
resolveThemePreference(initial?.theme ?? loadTheme(), process.env.REASONIX_THEME),
resolveThemePreference(initial?.theme ?? loadTheme(), loadThemeEnv()),
);

const [step, setStep] = useState<Step>("language");
const [data, setData] = useState<WizardData>(() => ({
language: getLanguage(),
theme: resolveThemePreference(initial?.theme ?? loadTheme(), process.env.REASONIX_THEME),
theme: resolveThemePreference(initial?.theme ?? loadTheme(), loadThemeEnv()),
apiKey: existingApiKey ?? "",
preset: initial?.preset ?? "auto",
selectedCatalog: deriveInitialCatalog(initial?.mcp ?? []),
Expand Down
4 changes: 2 additions & 2 deletions src/cli/ui/slash/handlers/observability.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { release } from "node:os";
import { loadRateLimit, loadTheme, resolveThemePreference } from "@/config.js";
import { loadRateLimit, loadTheme, loadThemeEnv, resolveThemePreference } from "@/config.js";
import { getLanguage, t } from "@/i18n/index.js";
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS, pricingFor } from "@/telemetry/stats.js";
import { countTokensBounded } from "@/tokenizer.js";
Expand Down Expand Up @@ -230,7 +230,7 @@ function estimateCost(userText: string, loop: import("@/loop.js").CacheFirstLoop
}

const feedback: SlashHandler = (_args, loop, ctx) => {
const themeName = resolveThemePreference(loadTheme(), process.env.REASONIX_THEME);
const themeName = resolveThemePreference(loadTheme(), loadThemeEnv());
const diagnostic = buildFeedbackDiagnostic({
version: VERSION,
latestVersion: ctx.latestVersion ?? undefined,
Expand Down
14 changes: 10 additions & 4 deletions src/cli/ui/slash/handlers/theme.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveThemePreference, saveTheme } from "@/config.js";
import { loadThemeEnv, resolveThemePreference, saveTheme } from "@/config.js";
import { t } from "@/i18n/index.js";
import { type ThemeName, isThemeName, listThemeNames } from "../../theme/tokens.js";
import type { SlashHandler } from "../dispatch.js";

Expand All @@ -13,12 +14,17 @@ const theme: SlashHandler = (args) => {
if (!next) return { openThemePicker: true };

if (!isThemeChoice(next)) {
return { info: `unknown theme: ${next}\navailable: ${themeChoices.join(", ")}` };
return {
info: t("handlers.theme.unknownTheme", {
theme: next,
available: themeChoices.join(", "),
}),
};
}

saveTheme(next);
const active = resolveThemePreference(next, process.env.REASONIX_THEME);
return { info: `theme saved: ${next}\nactive on next launch: ${active}` };
const active = resolveThemePreference(next, loadThemeEnv());
return { info: t("handlers.theme.saved", { theme: next, active }) };
};

export const handlers: Record<string, SlashHandler> = {
Expand Down
4 changes: 4 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,10 @@ export function loadTheme(path: string = defaultConfigPath()): ThemeName | "auto
return undefined;
}

export function loadThemeEnv(env: NodeJS.ProcessEnv = process.env): string | undefined {
return env.CARBONCODE_THEME ?? env.REASONIX_THEME;
}

export function resolveThemePreference(
configTheme: ThemeName | "auto" | undefined,
envTheme?: string | null,
Expand Down
14 changes: 13 additions & 1 deletion src/i18n/EN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,15 @@ export const EN: TranslationSchema = {
footer: "↑↓ pick · ⏎ confirm · esc cancel",
currentPref: "current preference",
activeNow: "active now",
autoDesc: "use REASONIX_THEME or default",
autoDesc: "use CARBONCODE_THEME or default",
autoLabel: "auto",
defaultLabel: "default",
darkLabel: "dark",
lightLabel: "light",
tokyoNightLabel: "tokyo-night",
githubDarkLabel: "github-dark",
githubLightLabel: "github-light",
highContrastLabel: "high-contrast",
},
planFlow: {
approveCardTitle: "Approve plan",
Expand Down Expand Up @@ -761,6 +769,10 @@ export const EN: TranslationSchema = {
titleStarted: "▸ naming session…",
titleFailed: "▸ session title failed: {reason}",
},
theme: {
saved: "theme saved: {theme}\nactive on next launch: {active}",
unknownTheme: "unknown theme: {theme}\navailable: {available}",
},
admin: {
doctorNeedsTui: "/doctor needs a TUI context (postDoctor wired).",
doctorRunning: "⚕ Doctor — running health checks…",
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,14 @@ export interface TranslationSchema {
currentPref: string;
activeNow: string;
autoDesc: string;
autoLabel: string;
defaultLabel: string;
darkLabel: string;
lightLabel: string;
tokyoNightLabel: string;
githubDarkLabel: string;
githubLightLabel: string;
highContrastLabel: string;
};
planFlow: {
approveCardTitle: string;
Expand Down
16 changes: 14 additions & 2 deletions src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,11 +469,19 @@ export const zhCN: TranslationSchema = {
themeSampleReasoning: "推理中",
},
themePicker: {
header: "主题",
header: "选择主题",
footer: "↑↓ 选择 · ⏎ 确认 · Esc 取消",
currentPref: "当前偏好",
activeNow: "当前生效",
autoDesc: "使用 REASONIX_THEME 或默认主题",
autoDesc: "使用 CARBONCODE_THEME 或默认主题",
autoLabel: "自动",
defaultLabel: "默认",
darkLabel: "深色",
lightLabel: "浅色",
tokyoNightLabel: "东京夜色",
githubDarkLabel: "GitHub 深色",
githubLightLabel: "GitHub 浅色",
highContrastLabel: "高对比度",
},
planFlow: {
approveCardTitle: "确认计划",
Expand Down Expand Up @@ -731,6 +739,10 @@ export const zhCN: TranslationSchema = {
titleStarted: "▸ 正在命名会话…",
titleFailed: "▸ 会话命名失败:{reason}",
},
theme: {
saved: "主题已保存:{theme}\n下次启动时生效:{active}",
unknownTheme: "未知主题:{theme}\n可用主题:{available}",
},
admin: {
doctorNeedsTui: "/doctor 需要 TUI 上下文(postDoctor 已连接)。",
doctorRunning: "⚕ 健康检查 — 正在运行…",
Expand Down
9 changes: 9 additions & 0 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
loadReasoningEffort,
loadSemanticEmbeddingUserConfig,
loadTheme,
loadThemeEnv,
markEditModeHintShown,
readConfig,
redactKey,
Expand Down Expand Up @@ -447,6 +448,14 @@ describe("config", () => {
expect(resolveThemePreference("auto", "unknown")).toBe("github-light");
});

it("loadThemeEnv prefers Carbon Code theme env over the legacy Reasonix env", () => {
expect(loadThemeEnv({ CARBONCODE_THEME: "tokyo-night", REASONIX_THEME: "github-dark" })).toBe(
"tokyo-night",
);
expect(loadThemeEnv({ REASONIX_THEME: "github-dark" })).toBe("github-dark");
expect(loadThemeEnv({})).toBeUndefined();
});

it("saveTheme doesn't clobber other persisted fields", () => {
saveEditMode("auto", path);
saveTheme("github-light", path);
Expand Down
17 changes: 17 additions & 0 deletions tests/slash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1244,6 +1244,7 @@ describe("handleSlash", () => {
});

afterEach(() => {
setLanguageRuntime("EN");
process.env.HOME = originalHome;
process.env.USERPROFILE = originalUserProfile;
if (originalTheme === undefined) {
Expand Down Expand Up @@ -1273,11 +1274,27 @@ describe("handleSlash", () => {
expect(loadTheme()).toBe("auto");
});

it("localizes theme feedback in zh-CN", () => {
setLanguageRuntime("zh-CN");
const r = handleSlash("theme", ["light"], makeLoop());
expect(r.info).toContain("主题已保存:light");
expect(r.info).toContain("下次启动时生效:light");
expect(loadTheme()).toBe("light");
});

it("rejects unknown theme names", () => {
const r = handleSlash("theme", ["solarized"], makeLoop());
expect(r.info).toMatch(/unknown theme: solarized/);
expect(loadTheme()).toBeUndefined();
});

it("localizes unknown theme names in zh-CN", () => {
setLanguageRuntime("zh-CN");
const r = handleSlash("theme", ["solarized"], makeLoop());
expect(r.info).toContain("未知主题:solarized");
expect(r.info).toContain("可用主题:auto");
expect(loadTheme()).toBeUndefined();
});
});

describe("/language", () => {
Expand Down
Loading