Skip to content
Merged
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
23 changes: 21 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,36 @@
</p>
</div>

Universal Code is a desktop AI coding workbench. Connect your chosen model and move a project forward in one conversation, with file changes, command results, and sources available for review.
Universal-Code is a desktop AI coding workbench. Connect your chosen model and move a project forward in one conversation, with file changes, command results, and sources available for review.

**v1.0.0 “First Voyage” is being prepared for release.** The [release notes](docs/releases/v1.0.0.md) record verified scope and remaining work. Public downloads still include older previews; new Mac packages are unnotarized previews.

## Why Universal Code?
## Why Universal-Code?

- **Work on a real project**: read and edit files, run builds, and inspect the resulting changes.
- **Fewer interruptions, with control**: select Full Access for a task to allow ordinary file changes and network commands to proceed automatically; file deletion still requires confirmation.
- **Keep research traceable**: prefer native search when the provider supports it, read GitHub, Hacker News, and RSS/Atom sources, and retain page snapshots and citations.
- **Continue existing work**: task history, tool records, and checkpoints stay on your machine so you can inspect progress and continue the conversation.

## Appearance

Choose **System, Light, Dark, or Mist** in **Settings → Appearance**. System follows your device’s light or dark setting. Mist uses a muted blue-gray canvas and light frosted surfaces, with adjustable blur, opacity, and brightness.

![The same Universal-Code conversation in Light and Dark, split diagonally](docs/images/appearance/light-dark.png)

Captured from a production build of the current source using a local demo project and offline sample replies. Light is on the left; Dark is on the right.

<details>
<summary>View theme options and Mist</summary>

![System, Light, Dark, and Mist theme options](docs/images/appearance/theme-options.png)

![Universal-Code in Mist](docs/images/appearance/conversation-mist.png)

</details>

See the [appearance guide](docs/usage.md#外观--appearance) for details.

## Quick start

### 1. Open the app
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,36 @@
</p>
</div>

Universal Code 是一个桌面 AI 编程工作台。连接你选择的模型,在同一段对话中推进项目,查看文件改动、命令结果和资料来源。
Universal-Code 是一个桌面 AI 编程工作台。连接你选择的模型,在同一段对话中推进项目,查看文件改动、命令结果和资料来源。

**v1.0.0「启航」正在准备发布。** [发布说明](docs/releases/v1.0.0.md)记录已验证范围和待完成事项;目前公开下载仍有历史预览,Mac 新包为未公证预览。

## 为什么选择 Universal Code
## 为什么选择 Universal-Code

- **围绕真实项目工作**:阅读和修改文件、运行构建、查看差异,把任务结果落到工作区。
- **少打断,保留控制**:选择当前任务的 Full Access 后,普通文件修改与联网命令可自动执行;文件删除仍需确认。
- **查资料时保留来源**:优先使用供应商支持的原生搜索,也能读取 GitHub、Hacker News 和 RSS/Atom,并保留网页快照与引用。
- **继续已有工作**:任务历史、工具记录和检查点保存在本机,可回看进度、检查改动和继续对话。

## 外观

在 **设置 → 外观** 中选择 **系统、浅色、深色、雾透**。系统模式随设备设置切换浅深;雾透使用灰蓝底色与轻盈的磨砂材质,还可调整模糊度、磨砂度和亮度。

![Universal-Code 同一段对话的浅色与深色外观,左浅右深斜向分割](docs/images/appearance/light-dark.png)

截图来自当前源码的生产构建,使用本地演示项目与离线示例回复。左侧为浅色,右侧为深色。

<details>
<summary>查看主题选项与雾透外观</summary>

![系统、浅色、深色、雾透四个主题选项](docs/images/appearance/theme-options.png)

![Universal-Code 雾透外观](docs/images/appearance/conversation-mist.png)

</details>

更多设置见[外观说明](docs/usage.md#外观--appearance)。

## 快速开始

### 1. 打开应用
Expand Down
Binary file added docs/images/appearance/conversation-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/appearance/conversation-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/appearance/conversation-mist.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/appearance/light-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/appearance/theme-options.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
28 changes: 28 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,34 @@ compatibility identifiers. The CLI binary and commands, `/api/v1/runs`,
`/api/v1/sessions`, OpenAPI schemas, event types, and SQLite `work_item` identities
remain unchanged. / 上述 CLI、API、协议、事件与 SQLite 标识保持不变。

## 外观 / Appearance

打开「设置 → 外观」选择主题,偏好保存在当前设备,重新打开应用后继续生效。
Open **Settings → Appearance** to choose a theme. Your choice is saved on the current device.

| 选项 / Option | 效果 / Behavior |
| --- | --- |
| 系统 / System | 随设备的浅色或深色设置即时切换,包括已打开的代码编辑器。Follows device appearance changes, including open code editors. |
| 浅色 / Light | 明亮的中性白色界面。A bright, neutral white interface. |
| 深色 / Dark | 深灰界面与玻璃材质。Dark gray surfaces with glass materials. |
| 雾透 / Mist | 灰蓝透色与轻盈磨砂,可调模糊度、磨砂度、背景亮度和光斑。Muted blue-gray frosted surfaces with adjustable blur, opacity, brightness, and ambient glow. |

![外观设置中的四个主题选项](images/appearance/theme-options.png)

系统选项的预览左右分色;其他预览展示对应主题的侧栏、对话与输入框。手动选择浅色、
深色或雾透后,设备设置的变化不会覆盖该选择。雾透的「还原默认值」恢复标准云母参数。
System uses a split preview; the other previews show their sidebar, conversation, and composer.
An explicit theme stays selected when the device appearance changes. Mist’s **Reset to defaults**
restores the standard Mica settings.

查看[浅色原图](images/appearance/conversation-light.png)、[深色原图](images/appearance/conversation-dark.png)
和[雾透原图](images/appearance/conversation-mist.png)。截图于 2026-10-19 在 Windows / Edge
中从生产构建拍摄,窗口为 1440 × 900;项目、回复和用量属于离线演示数据。
README 的分割图由同一对话的浅色与深色原图组合。
Screenshots were captured from a production build in Edge on Windows at 1440 × 900 on
2026-10-19. Project content, replies, and usage are offline demonstration data. The README
combines the Light and Dark captures of the same conversation.

## Portable Build Diagnostics

```powershell
Expand Down
11 changes: 8 additions & 3 deletions web/src/components/file-proposal-editor.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { APIClient } from "../api/client";
import type { FileEditProposalSourceView } from "../api/types";
import { FileProposalEditor } from "./file-proposal-editor";
import { applyPrayuTheme } from "../lib/appearance";

vi.mock("@monaco-editor/react", () => ({
default: ({ value, onChange }: { value: string; onChange: (value: string) => void }) =>
<textarea aria-label="Monaco proposal editor" onChange={(event) => onChange(event.target.value)}
default: ({ value, onChange, theme }: { value: string; onChange: (value: string) => void; theme: string }) =>
<textarea aria-label="Monaco proposal editor" data-theme={theme} onChange={(event) => onChange(event.target.value)}
value={value} />,
DiffEditor: ({ original, modified }: { original: string; modified: string }) =>
<pre aria-label="Monaco diff preview">{original}{modified}</pre>,
Expand All @@ -34,6 +35,10 @@ describe("FileProposalEditor", () => {
<FileProposalEditor client={client} onClose={vi.fn()} runID="run-1" source={source} />
</QueryClientProvider>);
const editor = await screen.findByLabelText("Monaco proposal editor");
await act(async () => { applyPrayuTheme("light", false); });
expect(editor).toHaveAttribute("data-theme", "vs");
await act(async () => { applyPrayuTheme("dark", false); });
expect(editor).toHaveAttribute("data-theme", "vs-dark");
await user.clear(editor);
await user.type(editor, "after");
await user.click(screen.getByRole("button", { name: "Create proposal" }));
Expand Down
4 changes: 2 additions & 2 deletions web/src/components/file-proposal-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Check, FileDiff, FileText, LoaderCircle, Pencil, RefreshCw, X } from "l
import type { APIClient } from "../api/client";
import type { FileEditProposalSourceView } from "../api/types";
import { useLocale } from "../lib/locale";
import { readPrayuTheme } from "../lib/appearance";
import { usePrayuTheme } from "../lib/appearance";
import { ErrorState } from "./common";
import { useFileEditDraft, useFileEditLeave, useFileEditLeaveEntry } from "./file-edit-drafts";

Expand Down Expand Up @@ -78,7 +78,7 @@ function FileProposalSession({ client, runID, source, onClose }: Parameters<type
const invalid = editorState !== "ready" || bytes > 256 * 1024 ||
draft === activeSource.content || expired || recovery.changedBase || recovery.conflict;
const language = languageForPath(activeSource.path);
const monacoTheme = readPrayuTheme() === "dark" ? "vs-dark" : "vs";
const monacoTheme = usePrayuTheme() === "dark" ? "vs-dark" : "vs";
useFileEditLeaveEntry({ path: activeSource.path, dirty: Boolean(recovery.draft),
busy: mutation.isPending, canSave: !invalid,
save: () => mutation.mutateAsync(recovery.captureSubmission(draft)), keep: recovery.keep,
Expand Down
14 changes: 10 additions & 4 deletions web/src/components/file-proposal-recovery.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { APIClient } from "../api/client";
import { FileProposalRecovery } from "./file-proposal-recovery";
import { applyPrayuTheme } from "../lib/appearance";

vi.mock("@monaco-editor/react", () => ({
DiffEditor: ({ original, modified }: { original: string; modified: string }) =>
<pre aria-label="Recovered proposal diff">{original}{modified}</pre>,
DiffEditor: ({ original, modified, theme }: { original: string; modified: string; theme: string }) =>
<pre aria-label="Recovered proposal diff" data-theme={theme}>{original}{modified}</pre>,
}));
vi.mock("../lib/monaco-local", () => ({ configureLocalMonaco: vi.fn() }));

Expand All @@ -22,7 +23,12 @@ it("renders a durable pending proposal as read-only stale review context", async
render(<QueryClientProvider client={new QueryClient()}>
<FileProposalRecovery client={client} editID="edit-1" onClose={vi.fn()} runID="run-1" />
</QueryClientProvider>);
expect(await screen.findByLabelText("Recovered proposal diff")).toHaveTextContent("before");
const editor = await screen.findByLabelText("Recovered proposal diff");
expect(editor).toHaveTextContent("before");
await act(async () => { applyPrayuTheme("light", false); });
expect(editor).toHaveAttribute("data-theme", "vs");
await act(async () => { applyPrayuTheme("dark", false); });
expect(editor).toHaveAttribute("data-theme", "vs-dark");
expect(screen.getByText("Workspace source changed")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /apply|approve|create proposal/i }))
.not.toBeInTheDocument();
Expand Down
5 changes: 3 additions & 2 deletions web/src/components/file-proposal-recovery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { DiffEditor } from "@monaco-editor/react";
import { AlertTriangle, History, X } from "lucide-react";
import type { APIClient } from "../api/client";
import { useLocale } from "../lib/locale";
import { readPrayuTheme } from "../lib/appearance";
import { usePrayuTheme } from "../lib/appearance";
import { ErrorState, LoadingState, StatusBadge } from "./common";

export function FileProposalRecovery({ client, runID, editID, onClose }: {
Expand All @@ -14,6 +14,7 @@ export function FileProposalRecovery({ client, runID, editID, onClose }: {
onClose: () => void;
}) {
const { t } = useLocale();
const theme = usePrayuTheme();
const [editorState, setEditorState] = useState<"loading" | "ready" | "failed">("loading");
const query = useQuery({
queryKey: ["run", runID, "file-edit-recovery", editID],
Expand Down Expand Up @@ -51,7 +52,7 @@ export function FileProposalRecovery({ client, runID, editID, onClose }: {
modified={recovery.proposed_content} original={recovery.original_content}
options={{ automaticLayout: true, fontFamily: 'var(--prayu-font-mono, "JetBrains Mono", Consolas, monospace)',
fontSize: 13, letterSpacing: 0, minimap: { enabled: false }, readOnly: true, renderSideBySide: true,
scrollBeyondLastLine: false }} theme={readPrayuTheme() === "dark" ? "vs-dark" : "vs"} /> :
scrollBeyondLastLine: false }} theme={theme === "dark" ? "vs-dark" : "vs"} /> :
<div className={`monaco-local-state${editorState === "failed" ? " inline-warning" : ""}`}
role={editorState === "failed" ? "alert" : "status"}>
{editorState === "failed" ? t("无法加载本地编辑器资源", "Local editor assets could not be loaded") : t("正在加载本地编辑器", "Loading local editor")}
Expand Down
53 changes: 52 additions & 1 deletion web/src/lib/appearance.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import {
applyPrayuGlassSettings,
applyPrayuTheme,
defaultPrayuGlassSettings,
initializePrayuAppearance,
prayuGlassPresets,
readPrayuGlassSettings,
readPrayuTheme,
readPrayuThemePreference,
resetPrayuGlassSettings,
usePrayuTheme,
} from "./appearance";

describe("Prayu appearance", () => {
Expand All @@ -21,10 +25,57 @@ describe("Prayu appearance", () => {
});

afterEach(() => {
applyPrayuTheme("dark", false);
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it("persists and restores the Catppuccin Latte glass mode with default Mica parameters", () => {
it("follows system changes, restores the preference, and stops following after a manual choice", async () => {
const media = Object.assign(new EventTarget(), { matches: true });
vi.stubGlobal("matchMedia", vi.fn(() => media));
const light = vi.fn();
const dark = vi.fn();
const previousRuntime = window.runtime;
window.runtime = { WindowSetLightTheme: light, WindowSetDarkTheme: dark };
try {
expect(readPrayuThemePreference()).toBe("system");
applyPrayuTheme("system");
expect(window.localStorage.getItem("prayu.theme")).toBe("system");
expect(document.documentElement.dataset.prayuTheme).toBe("light");
expect(light).toHaveBeenCalledOnce();
const { result, unmount } = renderHook(usePrayuTheme);
expect(result.current).toBe("light");

media.matches = false;
await act(async () => { media.dispatchEvent(new Event("change")); });
await waitFor(() => expect(result.current).toBe("dark"));
expect(readPrayuTheme()).toBe("dark");
expect(readPrayuThemePreference()).toBe("system");
expect(document.documentElement.dataset.prayuTheme).toBe("dark");
expect(document.documentElement.style.colorScheme).toBe("dark");
expect(dark).toHaveBeenCalledOnce();
unmount();

initializePrayuAppearance();
light.mockClear();
media.matches = true;
media.dispatchEvent(new Event("change"));
expect(document.documentElement.dataset.prayuTheme).toBe("light");
expect(light).toHaveBeenCalledOnce();

for (const theme of ["glass", "dark", "light"] as const) {
applyPrayuTheme(theme);
media.matches = !media.matches;
media.dispatchEvent(new Event("change"));
expect(document.documentElement.dataset.prayuTheme).toBe(theme);
expect(readPrayuThemePreference()).toBe(theme);
}
} finally {
window.runtime = previousRuntime;
}
});

it("persists and restores mist with default Mica parameters", () => {
applyPrayuTheme("glass");

expect(document.documentElement.dataset.prayuTheme).toBe("glass");
Expand Down
52 changes: 43 additions & 9 deletions web/src/lib/appearance.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useSyncExternalStore } from "react";
import { setDesktopWindowTheme } from "./desktop-window";

export type PrayuTheme = "light" | "dark" | "glass";
export type PrayuThemePreference = PrayuTheme | "system";
export type PrayuGlassMode = "mica" | "compat";
export type PrayuGlassPreset = "clear" | "standard" | "frosted";

Expand All @@ -14,6 +16,7 @@ export interface PrayuGlassSettings {

const themeStorageKey = "prayu.theme";
const glassStorageKey = "prayu.glass";
let stopWatchingSystemTheme: (() => void) | undefined;

export const defaultPrayuGlassSettings: Readonly<PrayuGlassSettings> = Object.freeze({
mode: "mica",
Expand Down Expand Up @@ -111,22 +114,53 @@ export function resetPrayuGlassSettings(persist = true): PrayuGlassSettings {
return normalized;
}

export function readPrayuTheme(): PrayuTheme {
if (typeof window === "undefined") return "dark";
export function readPrayuThemePreference(): PrayuThemePreference {
if (typeof window === "undefined") return "system";
try {
const stored = window.localStorage.getItem(themeStorageKey);
if (stored === "light" || stored === "dark" || stored === "glass") return stored;
if (stored === "system" || stored === "light" || stored === "dark" || stored === "glass") return stored;
} catch {
// Fall through to the system preference.
}
return window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
return "system";
}

export function readPrayuTheme(): PrayuTheme {
const preference = readPrayuThemePreference();
if (preference !== "system") return preference;
return typeof window !== "undefined" && window.matchMedia?.("(prefers-color-scheme: light)").matches ? "light" : "dark";
}

function subscribeToTheme(onChange: () => void): () => void {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-prayu-theme"] });
return () => observer.disconnect();
}

export function applyPrayuTheme(theme: PrayuTheme, persist = true): void {
document.documentElement.dataset.prayuTheme = theme;
document.documentElement.style.colorScheme = theme === "dark" ? "dark" : "light";
function readAppliedTheme(): PrayuTheme {
return (document.documentElement.dataset.prayuTheme as PrayuTheme | undefined) ?? readPrayuTheme();
}

export function usePrayuTheme(): PrayuTheme {
return useSyncExternalStore(subscribeToTheme, readAppliedTheme, readPrayuTheme);
}

export function applyPrayuTheme(theme: PrayuThemePreference, persist = true): void {
stopWatchingSystemTheme?.();
stopWatchingSystemTheme = undefined;
const systemTheme = theme === "system" ? window.matchMedia?.("(prefers-color-scheme: light)") : undefined;
const syncTheme = () => {
const resolved = theme === "system" ? systemTheme?.matches ? "light" : "dark" : theme;
document.documentElement.dataset.prayuTheme = resolved;
document.documentElement.style.colorScheme = resolved === "dark" ? "dark" : "light";
setDesktopWindowTheme(resolved);
};
syncTheme();
syncGlassVariables(readPrayuGlassSettings());
setDesktopWindowTheme(theme);
if (systemTheme) {
systemTheme.addEventListener("change", syncTheme);
stopWatchingSystemTheme = () => systemTheme.removeEventListener("change", syncTheme);
}
if (!persist) return;
try {
window.localStorage.setItem(themeStorageKey, theme);
Expand All @@ -136,6 +170,6 @@ export function applyPrayuTheme(theme: PrayuTheme, persist = true): void {
}

export function initializePrayuAppearance(): void {
applyPrayuTheme(readPrayuTheme(), false);
applyPrayuTheme(readPrayuThemePreference(), false);
}

Loading
Loading