Add security hardening, Ghidra, recreate skill, checkpoints, shells, web search and themes

- Sandbox (bubblewrap) for non-admin shell/git, MCP per-user rights, hard budgets and quotas,
  login rate limiting, audit log and backups
- Ghidra headless decompilation (ARM64 natives built from source), binary_info tool,
  clean room / deep workflow per session
- Recreate skill and commands for analysing and re-building old software
- File checkpoints with rewind/restore, background shells (bash_output, kill_shell)
- web_search tool (DuckDuckGo, SearXNG, Brave, Tavily)
- In-app dialogs and toasts instead of browser dialogs
- 15 base themes (trilium-themes palettes plus Nord, VS Code, GitHub, Catppuccin, One,
  Dracula, Rose Pine, Everforest, Kanagawa) with light/dark/auto switcher and license notes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 19:12:30 +01:00
1 parent fab9add5d5
commit 5b569b6835
108 files changed
+6785 -279

No files matched your search

+9 -1
View File
@@ -12,6 +12,7 @@ import { Settings } from "./components/Settings";
import { Stats } from "./components/Stats";
import { Images } from "./components/Images";
import { Spinner, useTitleActivity } from "./components/Activity";
import { DialogHost } from "./dialogs";
// The file manager (editor, git) is loaded on demand to keep the chat small.
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
@@ -58,7 +59,13 @@ export function App() {
if (error) return <div className="empty">{error}</div>;
if (!info) return <div className="empty">{t("common.loading")}</div>;
if (!info.user) return <Login info={info} onSuccess={refresh} />;
if (!info.user)
return (
<>
<Login info={info} onSuccess={refresh} />
<DialogHost />
</>
);
const [section, arg] = route;
let content;
@@ -79,6 +86,7 @@ export function App() {
{menuOpen && <div className="backdrop" onClick={() => setMenuOpen(false)} />}
<Sidebar open={menuOpen} activeId={section === "s" ? arg : undefined} section={section ?? ""} />
<main className="main">{content}</main>
<DialogHost />
</div>
);
}
+94 -25
View File
@@ -1,9 +1,8 @@
import { useEffect } from "react";
import { useT } from "../i18n";
import { useEffect, useState } from "react";
import { fmtCompact, useT } from "../i18n";
import { useStore, type Draft } from "../store";
import type { Part } from "../types";
type ToolResult = Extract<Part, { type: "tool_result" }>;
/** Animated three-dot pulse. */
export function Dots() {
@@ -20,43 +19,113 @@ export function Spinner({ small }: { small?: boolean }) {
return <span className={`spinner ${small ? "small" : ""}`} aria-hidden="true" />;
}
/** What the agent is doing right now, derived from the streaming message. */
const STARS = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
/** Rotating star like a terminal agent's spinner. */
function Star() {
const [i, setI] = useState(0);
useEffect(() => {
const h = setInterval(() => setI((x) => (x + 1) % STARS.length), 120);
return () => clearInterval(h);
}, []);
return <span className="star" aria-hidden="true">{STARS[i]}</span>;
}
function useNow(active: boolean) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (!active) return;
const h = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(h);
}, [active]);
return now;
}
function fmtElapsed(ms: number) {
const s = Math.max(0, Math.floor(ms / 1000));
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
/** Generated tokens so far, estimated from the streamed characters. */
function generatedTokens(draft?: Draft) {
if (!draft) return 0;
let chars = 0;
for (const p of draft.parts) {
if (p.type === "text" || p.type === "reasoning") chars += p.text.length;
else if (p.type === "tool_call") chars += JSON.stringify(p.input ?? {}).length;
}
return Math.round(chars / 4);
}
/** What the agent is doing right now – playful text, elapsed time and tokens. */
export function ActivityLine({
sessionId,
draft,
results,
pendingTool,
waiting,
permissionPending,
}: {
sessionId: string;
draft?: Draft;
results: Map<string, ToolResult>;
/** A tool call of the last (finished) model step that has no result yet. */
pendingTool?: { name: string; input: any; since: number };
waiting?: string;
permissionPending: boolean;
}) {
const t = useT();
let label: string;
const step = useStore((s) => s.steps[sessionId]);
const now = useNow((!!step || !!pendingTool) && !permissionPending);
if (permissionPending) {
label = t("activity.waitingForYou");
} else if (waiting) {
return (
<div className="activity paused" role="status">
<span className="pause-icon" aria-hidden="true" />
<span>{t("activity.waitingForYou")}</span>
</div>
);
}
if (waiting) {
const [kind, ...rest] = waiting.split(":");
const what = rest.join(":");
label = kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what });
} else {
const last = draft?.parts[draft.parts.length - 1];
const runningTool = draft?.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as
| Extract<Part, { type: "tool_call" }>
| undefined;
if (runningTool) {
label = runningTool.name === "task" ? t("activity.subagent", { name: runningTool.input?.description ?? "" }) : t("activity.tool", { tool: runningTool.name });
} else if (last?.type === "text") {
label = t("activity.writing");
} else {
label = t("activity.thinking");
}
return (
<div className="activity" role="status">
<Dots />
<span>{kind === "model" ? t("chat.waitingModel", { model: what }) : t("chat.waitingSubagent", { agent: what === "*" ? t("chat.anySubagent") : what })}</span>
</div>
);
}
// A tool is running between two model steps.
if (!draft && pendingTool) {
const label = pendingTool.name === "task" ? t("activity.subagent", { name: pendingTool.input?.description ?? "" }) : t("activity.tool", { tool: pendingTool.name });
return (
<div className="activity" role="status" aria-live="polite">
<Star />
<span className="activity-text">{label}</span>
<span className="activity-meta">({fmtElapsed(now - pendingTool.since)})</span>
</div>
);
}
const live = draft && step && draft.id === step.messageId ? draft : undefined;
const generated = generatedTokens(live);
const phrases = t("activity.phrases").split("|");
// A stable starting phrase per step, changing every 4 seconds.
const seed = step ? [...step.messageId].reduce((a, c) => a + c.charCodeAt(0), 0) : 0;
const elapsed = step ? now - step.startedAt : 0;
const label = `${phrases[(seed + Math.floor(elapsed / 4000)) % phrases.length]}…`;
const prefill = step && generated === 0;
return (
<div className={`activity ${permissionPending ? "paused" : ""}`} role="status" aria-live="polite">
{permissionPending ? <span className="pause-icon" aria-hidden="true" /> : <Dots />}
<span>{label}</span>
<div className="activity" role="status" aria-live="polite">
<Star />
<span className="activity-text">{label}</span>
{step && (
<span className="activity-meta">
({fmtElapsed(elapsed)} · ↑ {step.estimated ? "~" : ""}
{fmtCompact(step.promptTokens)} {t("activity.prefill")} · ↓ {prefill ? t("activity.prefilling") : `${fmtCompact(generated)} ${t("activity.generated")}`})
</span>
)}
</div>
);
}
+30 -57
View File
@@ -12,8 +12,12 @@ import { ShareDialog } from "./ShareDialog";
import { QueueList } from "./QueueList";
import { BudgetChip } from "./Budget";
import { PermissionCard, QuestionCard } from "./Interaction";
import { Markdown } from "./Markdown";
import { ImageRefs, Markdown } from "./Markdown";
import { UsageOverlay } from "./UsageOverlay";
import { RewindDialog } from "./RewindDialog";
import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs";
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -53,7 +57,7 @@ function Reasoning({ text, live }: { text: string; live: boolean }) {
);
}
function AssistantParts({ parts, results, running, live }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean }) {
function AssistantParts({ parts, results, running, live, baseDir }: { parts: Part[]; results: Map<string, ToolResult>; running: boolean; live: boolean; baseDir: string }) {
return (
<>
{parts.map((p, i) => {
@@ -61,13 +65,14 @@ function AssistantParts({ parts, results, running, live }: { parts: Part[]; resu
case "text":
return (
<div key={i} className={live && i === parts.length - 1 ? "streaming" : undefined}>
<Markdown text={p.text} />
<Markdown text={p.text} baseDir={baseDir} />
{!live && <ImageRefs text={p.text} baseDir={baseDir} />}
</div>
);
case "reasoning":
return <Reasoning key={i} text={p.text} live={live && i === parts.length - 1} />;
case "tool_call":
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} />;
return <ToolCard key={p.id} call={p} result={results.get(p.id)} running={running} baseDir={baseDir} />;
default:
return null;
}
@@ -102,46 +107,6 @@ function BackgroundResult({ text }: { text: string }) {
);
}
function StatsPanel({ stats }: { stats: SessionStats }) {
const t = useT();
const a = stats.total;
return (
<div className="panel" style={{ margin: "8px 0" }}>
<table className="grid">
<thead>
<tr>
<th>{t("stats.model")}</th>
<th className="num">{t("stats.requests")}</th>
<th className="num">{t("stats.input")}</th>
<th className="num">{t("stats.cacheRead")}</th>
<th className="num">{t("stats.output")}</th>
<th className="num">{t("stats.ttft")}</th>
<th className="num">{t("stats.outputSpeed")}</th>
<th className="num">{t("stats.cost")}</th>
</tr>
</thead>
<tbody>
{[...stats.by_model, { ...a, model: t("stats.total") }].map((r) => (
<tr key={r.model}>
<td>{r.model}</td>
<td className="num">{fmtNumber(r.requests)}</td>
<td className="num">{fmtCompact(r.input_tokens + r.cache_write_tokens)}</td>
<td className="num">{fmtCompact(r.cache_read_tokens)}</td>
<td className="num">{fmtCompact(r.output_tokens)}</td>
<td className="num">{fmtNumber(r.avg_ttft_ms)} ms</td>
<td className="num">{fmtNumber(r.output_tps, 1)} tok/s</td>
<td className="num">{fmtCost(r.cost, stats.currency)}</td>
</tr>
))}
</tbody>
</table>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>
{t("stats.prefillSpeed")}: {fmtNumber(a.prefill_tps)} tok/s · {t("stats.cacheHit")}: {fmtPercent(a.cache_hit_rate)}
</div>
</div>
);
}
export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: () => void }) {
const t = useT();
const session = useStore((s) => s.sessionById[sessionId]);
@@ -195,7 +160,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
try {
await patch(`/sessions/${sessionId}`, p);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -210,20 +175,21 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
setText("");
stick.current = true;
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
const rewind = async (m: Message) => {
if (!confirm(t("chat.rewindConfirm"))) return;
const txt = m.parts.map((p) => (p.type === "text" ? p.text : "")).join("");
await post(`/sessions/${sessionId}/rewind`, { message_id: m.id });
const [rewinding, setRewinding] = useState<Message>();
const rewind = (m: Message) => setRewinding(m);
const rewindDone = async () => {
const m = rewinding!;
setRewinding(undefined);
await loadSession(sessionId);
setText(txt);
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
};
const remove = async () => {
if (!confirm(t("chat.deleteConfirm"))) return;
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
await del(`/sessions/${sessionId}`);
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
};
@@ -237,6 +203,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
const todos = Array.isArray(session.todos) ? session.todos : [];
const openTodos = todos.filter((x) => x.status !== "completed");
const lastIsAssistant = messages.length > 0 && messages[messages.length - 1].role === "assistant";
const lastMsg = messages[messages.length - 1];
const pendingCall = lastIsAssistant
? (lastMsg.parts.filter((p) => p.type === "tool_call" && !results.has(p.id)).pop() as Extract<Part, { type: "tool_call" }> | undefined)
: undefined;
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
return (
<>
@@ -279,6 +250,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
{fmtCost(stats.total.cost, stats.currency)}
</button>
)}
<ShellsChip sessionId={sessionId} busy={busy} />
{isAdmin && <BudgetChip />}
{session.permission_mode === "bypass" && <span className="chip warn">{t("permissionMode.bypass")}</span>}
{readOnly && <span className="chip">{t("chat.readOnly")}</span>}
@@ -289,7 +261,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
⇄
</button>
)}
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => alert(e.message))} disabled={busy} title={t("command.compact")}>
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
⇊
</button>
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
@@ -308,13 +280,13 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
}}
>
<div className="chat">
{showStats && stats && <StatsPanel stats={stats} />}
{showStats && <UsageOverlay session={session} onClose={() => setShowStats(false)} />}
{messages.length === 0 && !draft && <div className="empty">{t("chat.empty")}</div>}
{messages.map((m) => {
if (m.role === "assistant") {
return (
<div key={m.id} className="msg">
<AssistantParts parts={m.parts} results={results} running={busy} live={false} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={session.cwd} />
</div>
);
}
@@ -367,11 +339,11 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
})}
{draft && (
<div className="msg">
<AssistantParts parts={draft.parts} results={results} running={busy} live />
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div>
)}
{(busy || waiting) && (
<ActivityLine draft={draft} results={results} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
<ActivityLine sessionId={sessionId} draft={draft} pendingTool={pendingTool} waiting={waiting} permissionPending={permissions.length > 0 || questions.length > 0} />
)}
{openTodos.length > 0 && (busy || permissions.length > 0 || questions.length > 0) && (
<div className="todos">
@@ -409,6 +381,7 @@ export function ChatView({ sessionId, onMenu }: { sessionId: string; onMenu: ()
</div>
</div>
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
{readOnly ? (
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
+13 -2
View File
@@ -3,6 +3,7 @@ import { useAgents, useFetch } from "../hooks";
import { useT, type Key } from "../i18n";
import type { ImageData, Session } from "../types";
import { ModelSelect } from "./ModelSelect";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface Suggestion {
name: string;
@@ -119,7 +120,7 @@ export function Composer({
const imgs = await Promise.all(all.filter((f) => f.type.startsWith("image/")).map(readImage));
setImages((cur) => [...cur, ...imgs]);
} catch (e: any) {
alert(e.message ?? String(e));
toast(e.message ?? String(e));
}
// Everything else is uploaded into the project folder.
for (const f of all.filter((f) => !f.type.startsWith("image/"))) {
@@ -135,7 +136,7 @@ export function Composer({
if (!res.ok) throw new Error(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
setFiles((cur) => [...cur, { name: f.name, path, size: f.size }]);
} catch (e: any) {
alert(e.message ?? String(e));
toast(e.message ?? String(e));
} finally {
setUploading((n) => n - 1);
}
@@ -238,6 +239,16 @@ export function Composer({
</button>
))}
</div>
{session.agent === "craft" && (
<div className="mode-tabs" role="tablist" aria-label={t("craft.mode")} title={t("craft.modeHint")}>
<button className={session.clean_room !== false ? "active" : ""} onClick={() => onUpdate({ clean_room: true })}>
{t("craft.cleanRoom")}
</button>
<button className={session.clean_room === false ? "active deep" : ""} onClick={() => onUpdate({ clean_room: false })}>
{t("craft.deep")}
</button>
</div>
)}
<select
value={session.permission_mode}
onChange={(e) => onUpdate({ permission_mode: e.target.value as Session["permission_mode"] })}
+141
View File
@@ -0,0 +1,141 @@
import { lazy, Suspense, useEffect, useState } from "react";
import { get } from "../api";
import { useT } from "../i18n";
import { Spinner } from "./Activity";
const ModelViewer = lazy(() => import("./ModelViewer").then((m) => ({ default: m.ModelViewer })));
export type PreviewKind = "image" | "pdf" | "doc" | "audio" | "module" | "model";
const KINDS: [RegExp, PreviewKind][] = [
[/\.(png|jpe?g|gif|webp|bmp|svg)$/i, "image"],
[/\.pdf$/i, "pdf"],
[/\.(docx|pptx|xlsx|xlsm|xls|ods)$/i, "doc"],
[/\.(wav|mp3|ogg|oga|flac|m4a)$/i, "audio"],
[/\.(xm|mod|s3m|it)$/i, "module"],
[/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i, "model"],
];
export function previewKind(path: string): PreviewKind | undefined {
return KINDS.find(([re]) => re.test(path))?.[1];
}
const q = (p: string) => `path=${encodeURIComponent(p)}`;
function PdfPreview({ path }: { path: string }) {
const t = useT();
const [pages, setPages] = useState<number>();
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
get<{ pages: number }>(`/files/preview/pdf-info?${q(path)}`).then((r) => setPages(r.pages)).catch(() => setPages(1));
}, [path]);
return (
<div className="pdf-preview">
<div className="row pdf-nav">
<button className="small" disabled={page <= 1} onClick={() => setPage(page - 1)}>
‹
</button>
<span>{t("preview.page", { n: page, total: pages ?? "…" })}</span>
<button className="small" disabled={!pages || page >= pages} onClick={() => setPage(page + 1)}>
›
</button>
<span className="spacer" />
<a className="button small" href={`/api/files/raw?${q(path)}`} target="_blank" rel="noreferrer">
{t("preview.openBrowser")}
</a>
</div>
<div className="pane-scroll image-preview">
<img key={page} src={`/api/files/preview/pdf-page?${q(path)}&page=${page}`} alt={`page ${page}`} />
</div>
</div>
);
}
interface DocData {
kind: "text" | "table";
text?: string;
sheets?: { name: string; rows: string[][]; total_rows: number }[];
}
function DocPreview({ path }: { path: string }) {
const t = useT();
const [data, setData] = useState<DocData>();
const [error, setError] = useState<string>();
const [sheet, setSheet] = useState(0);
useEffect(() => {
setData(undefined);
setSheet(0);
get<DocData>(`/files/preview/doc?${q(path)}`).then(setData).catch((e) => setError(e.message));
}, [path]);
if (error) return <div className="notice err">{error}</div>;
if (!data) return <div className="empty"><Spinner /></div>;
if (data.kind === "text") return <pre className="pane-scroll doc-text">{data.text}</pre>;
const s = data.sheets?.[sheet];
return (
<div className="pdf-preview">
<div className="mode-tabs sheet-tabs">
{data.sheets?.map((x, i) => (
<button key={x.name} className={i === sheet ? "active" : ""} onClick={() => setSheet(i)}>
{x.name}
</button>
))}
</div>
<div className="pane-scroll">
{s && (
<table className="grid sheet">
<tbody>
{s.rows.map((r, i) => (
<tr key={i}>
<th className="num">{i + 1}</th>
{r.map((c, j) => (
<td key={j}>{c}</td>
))}
</tr>
))}
</tbody>
</table>
)}
{s && s.total_rows > s.rows.length && <div className="empty">{t("preview.moreRows", { n: s.total_rows - s.rows.length })}</div>}
</div>
</div>
);
}
export function FilePreview({ path, kind }: { path: string; kind: PreviewKind }) {
const t = useT();
const raw = `/api/files/raw?${q(path)}`;
switch (kind) {
case "image":
return (
<div className="pane-scroll image-preview">
<img src={raw} alt="" />
</div>
);
case "pdf":
return <PdfPreview path={path} />;
case "doc":
return <DocPreview path={path} />;
case "audio":
return (
<div className="audio-preview">
<audio controls src={raw} preload="metadata" />
</div>
);
case "module":
return (
<div className="audio-preview">
<div className="muted">{t("preview.moduleHint")}</div>
<audio controls src={`/api/files/preview/module?${q(path)}`} preload="none" />
</div>
);
case "model": {
const ext = path.split(".").pop()!.toLowerCase();
return (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<ModelViewer url={raw} ext={ext} />
</Suspense>
);
}
}
}
+39 -24
View File
@@ -6,6 +6,8 @@ import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
@@ -31,12 +33,13 @@ interface OpenFile {
mtime?: number;
binary: boolean;
tooLarge?: boolean;
preview?: PreviewKind;
}
/** What the right pane shows. */
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
const IMAGE = /\.(png|jpe?g|gif|webp)$/i;
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
@@ -44,6 +47,9 @@ const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) {
if (e.is_dir) return "📁";
if (IMAGE.test(e.name)) return "🖼";
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return "🎵";
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return "🧊";
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return "📑";
if (/\.(md|txt)$/i.test(e.name)) return "📝";
return "📄";
}
@@ -112,17 +118,24 @@ export function Files({ onMenu }: { onMenu: () => void }) {
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
const confirmDiscard = () => !dirty || confirm(t("files.discardChanges"));
const confirmDiscard = async () => !dirty || (await confirmDialog({ message: t("files.discardChanges"), danger: true, confirmLabel: t("files.discard") }));
const openFile = async (path: string) => {
if (!confirmDiscard()) return;
if (!(await confirmDiscard())) return;
const kind = previewKind(path);
// Previewable files (except SVG, which can also be edited) are not loaded as text.
if (kind && !/\.svg$/i.test(path)) {
setPane({ kind: "file", file: { path, content: "", saved: "", binary: true, preview: kind } });
setMobileEditor(true);
return;
}
try {
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
setPreview(/\.md$/i.test(path));
setPreview(/\.(md|svg)$/i.test(path));
setMobileEditor(true);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -135,8 +148,8 @@ export function Files({ onMenu }: { onMenu: () => void }) {
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
void refreshGit();
} catch (e: any) {
if (e.status === 409 && confirm(t("files.conflict"))) return save(true);
alert(e.message);
if (e.status === 409 && (await confirmDialog({ message: t("files.conflict"), danger: true, confirmLabel: t("files.overwrite") }))) return save(true);
toast(e.message);
}
},
[pane, refreshGit, t],
@@ -148,12 +161,12 @@ export function Files({ onMenu }: { onMenu: () => void }) {
if (listing) await load(listing.path);
void refreshGit();
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
const newEntry = (isDir: boolean) => {
const name = prompt(isDir ? t("files.newFolderName") : t("files.newFileName"));
const newEntry = async (isDir: boolean) => {
const name = await promptDialog({ message: isDir ? t("files.newFolderName") : t("files.newFileName") });
if (!name || !listing) return;
const path = `${listing.path}/${name}`;
void act(async () => {
@@ -162,18 +175,18 @@ export function Files({ onMenu }: { onMenu: () => void }) {
});
};
const rename = (e: Entry) => {
const name = prompt(t("files.renameTo"), e.name);
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
};
const duplicate = (e: Entry) => {
const name = prompt(t("files.copyTo"), `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}`);
const duplicate = async (e: Entry) => {
const name = await promptDialog({ message: t("files.copyTo"), defaultValue: `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}` });
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
};
const remove = (e: Entry) => {
if (confirm(t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }))) {
const remove = async (e: Entry) => {
if (await confirmDialog({ message: t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }), danger: true, confirmLabel: t("common.delete") })) {
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
}
@@ -183,7 +196,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
if (!listing) return;
for (const f of [...files]) {
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
if (!res.ok) alert(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
if (!res.ok) toast(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
}
await load(listing.path);
void refreshGit();
@@ -220,7 +233,7 @@ export function Files({ onMenu }: { onMenu: () => void }) {
>
<div className="files-toolbar">
{roots.length > 1 && (
<select value={root?.path ?? ""} onChange={(e) => confirmDiscard() && load(e.target.value)} aria-label={t("images.folder")}>
<select value={root?.path ?? ""} onChange={(e) => { const v = e.target.value; void confirmDiscard().then((ok): unknown => ok && load(v)); }} aria-label={t("images.folder")}>
{roots.map((r) => (
<option key={r.path} value={r.path}>
{r.name}
@@ -335,14 +348,14 @@ export function Files({ onMenu }: { onMenu: () => void }) {
return (
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => confirmDiscard() && setMobileEditor(false)}>
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
←
</button>
<b className="grow" title={f.path}>
{baseName(f.path)}
{dirty && <span className="dirty"> ●</span>}
</b>
{isMd && (
{(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}>
{preview ? t("files.edit") : t("files.preview")}
</button>
@@ -356,7 +369,9 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
)}
</div>
{isImage ? (
{f.preview ? (
<FilePreview path={f.path} kind={f.preview} />
) : isImage && preview ? (
<div className="pane-scroll image-preview">
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
</div>
@@ -422,10 +437,10 @@ export function Files({ onMenu }: { onMenu: () => void }) {
</button>
<button
className="small"
onClick={() => {
const url = prompt(t("git.cloneUrl"));
onClick={async () => {
const url = await promptDialog({ message: t("git.cloneUrl"), placeholder: "https://…/repo.git" });
if (!url) return;
const name = prompt(t("git.cloneFolder"), baseName(url).replace(/\.git$/, ""));
const name = await promptDialog({ message: t("git.cloneFolder"), defaultValue: baseName(url).replace(/\.git$/, "") });
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
}}
>
+5 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { post } from "../api";
import { fmtRelative, useT } from "../i18n";
import { Spinner } from "./Activity";
import { confirmDialog, promptDialog, toast } from "../dialogs";
export interface GitFile {
path: string;
@@ -90,7 +91,7 @@ export function GitPanel({
const r = await call<{ diff: string }>("diff", { files: [abs(f)], staged: isStaged });
showDiff(`${f.path}${isStaged ? ` (${t("git.staged")})` : ""}`, r.diff);
} catch (e: any) {
alert(e.message);
toast(e.message);
}
};
@@ -112,7 +113,7 @@ export function GitPanel({
) : (
<>
{f.index !== "?" && (
<button className="small ghost danger" title={t("git.discard")} onClick={() => confirm(t("git.discardConfirm", { file: f.path })) && run("discard", () => call("discard", { files: [abs(f)] }))}>
<button className="small ghost danger" title={t("git.discard")} onClick={() => void confirmDialog({ message: t("git.discardConfirm", { file: f.path }), danger: true, confirmLabel: t("git.discard") }).then((ok): unknown => ok && run("discard", () => call("discard", { files: [abs(f)] })))}>
↺
</button>
)}
@@ -143,8 +144,8 @@ export function GitPanel({
<button
className="small"
title={t("git.newBranch")}
onClick={() => {
const name = prompt(t("git.newBranchName"));
onClick={async () => {
const name = await promptDialog({ message: t("git.newBranchName"), placeholder: "feature/…" });
if (name) void run("checkout", () => call("checkout", { branch: name, create: true }), true);
}}
>
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post } from "../api";
import { useFetch } from "../hooks";
import { fmtCost, fmtDate, useT, type Key } from "../i18n";
import { Spinner } from "./Activity";
import { confirmDialog, promptDialog, toast } from "../dialogs";
interface ImageConfig {
enabled: boolean;
@@ -148,7 +149,7 @@ export function Images({ onMenu }: { onMenu: () => void }) {
<button className="small" title={t("images.copyPath")} onClick={() => navigator.clipboard?.writeText(img.path)}>
⧉
</button>
<button className="small danger" title={t("common.delete")} onClick={() => confirm(t("images.deleteConfirm")) && del(`/images/${img.id}`).then(reload)}>
<button className="small danger" title={t("common.delete")} onClick={() => void confirmDialog({ message: t("images.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && del(`/images/${img.id}`).then(reload))}>
🗑
</button>
</div>
+100 -3
View File
@@ -1,18 +1,115 @@
import { memo } from "react";
import { memo, useState, type ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeHighlight from "rehype-highlight";
export const Markdown = memo(function Markdown({ text }: { text: string }) {
export const IMAGE_EXT = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
/** URL for a file in the project (absolute or relative to baseDir). */
export function projectFileUrl(src: string, baseDir?: string): string | undefined {
if (/^(https?:|data:|blob:)/i.test(src)) return src;
if (!baseDir && !src.startsWith("/")) return undefined;
const parts = (src.startsWith("/") ? src : `${baseDir}/${src}`).split("/");
const out: string[] = [];
for (const p of parts) {
if (p === "..") out.pop();
else if (p && p !== ".") out.push(p);
}
return `/api/files/raw?path=${encodeURIComponent("/" + out.join("/"))}`;
}
function textOf(node: ReactNode): string {
if (typeof node === "string") return node;
if (Array.isArray(node)) return node.map(textOf).join("");
if (node && typeof node === "object" && "props" in node) return textOf((node as any).props.children);
return "";
}
/** SVG code shown as an image (img context: scripts never run), code on demand. */
export function SvgBlock({ code, children }: { code: string; children?: ReactNode }) {
const [showCode, setShowCode] = useState(false);
const url = `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(code)))}`;
return (
<div className="svg-block">
<img src={url} alt="SVG" />
<button className="small ghost" onClick={() => setShowCode(!showCode)}>
{showCode ? "▾ SVG" : "▸ SVG"}
</button>
{showCode && children}
</div>
);
}
export const Markdown = memo(function Markdown({ text, baseDir }: { text: string; baseDir?: string }) {
return (
<div className="markdown">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
components={{ a: (props) => <a {...props} target="_blank" rel="noreferrer" /> }}
components={{
a: (props) => <a {...props} target="_blank" rel="noreferrer" />,
img: ({ src, alt }) => {
const url = typeof src === "string" ? projectFileUrl(src, baseDir) : undefined;
if (!url) return <span>{alt}</span>;
// Non-image files (3D models, documents) become links.
if (typeof src === "string" && !/^(data:|https?:)/i.test(src) && !IMAGE_EXT.test(src)) {
return (
<a href={url} target="_blank" rel="noreferrer">
📎 {alt || src}
</a>
);
}
return <img className="inline-image" src={url} alt={alt ?? ""} loading="lazy" />;
},
pre: ({ children }) => {
const code = textOf(children);
const child: any = Array.isArray(children) ? children[0] : children;
const cls = String(child?.props?.className ?? "");
if (/language-svg/.test(cls) || (/language-(xml|html)/.test(cls) && code.trim().startsWith("<svg"))) {
return (
<SvgBlock code={code}>
<pre>{children}</pre>
</SvgBlock>
);
}
return <pre>{children}</pre>;
},
}}
>
{text}
</ReactMarkdown>
</div>
);
});
/** Image files mentioned in a text (paths in backticks or plain). */
export function imagePathsIn(text: string): string[] {
const found = new Set<string>();
const re = /(?:^|[\s`(\["'])((?:\.{0,2}\/)?[\w\-./]+\.(?:png|jpe?g|gif|webp|bmp|svg))(?=$|[\s`)\]"',.:;!?])/gim;
for (const m of text.matchAll(re)) {
// Skip URLs and markdown images (rendered anyway).
const idx = m.index ?? 0;
const before = text.slice(Math.max(0, idx - 2), idx + 1);
if (m[1].includes("://") || before.includes("](") || text.slice(Math.max(0, idx - 10), idx + 1).includes("http")) continue;
found.add(m[1]);
}
return [...found].slice(0, 8);
}
/** Thumbnails for image files mentioned in a message; missing files hide themselves. */
export function ImageRefs({ text, baseDir }: { text: string; baseDir?: string }) {
const paths = imagePathsIn(text.replace(/!\[[^\]]*\]\([^)]*\)/g, ""));
if (paths.length === 0 || !baseDir) return null;
return (
<div className="image-refs">
{paths.map((p) => {
const url = projectFileUrl(p, baseDir)!;
return (
<a key={p} href={url} target="_blank" rel="noreferrer" title={p}>
<img src={url} alt={p} loading="lazy" onError={(e) => ((e.currentTarget.parentElement as HTMLElement).style.display = "none")} />
</a>
);
})}
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
// 3D preview for GLB/GLTF, STL, OBJ and STEP/IGES (via OpenCascade WASM).
// Loaded on demand only when such a file is opened.
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";
import occtScriptUrl from "occt-import-js/dist/occt-import-js.js?url";
import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url";
let occtPromise: Promise<any> | undefined;
/** Load the OpenCascade importer once (script tag + wasm URL). */
function loadOcct(): Promise<any> {
occtPromise ??= new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = occtScriptUrl;
s.onload = () => (window as any).occtimportjs({ locateFile: () => occtWasmUrl }).then(resolve, reject);
s.onerror = () => reject(new Error("could not load the STEP importer"));
document.head.appendChild(s);
});
return occtPromise;
}
function cssColor(name: string, fallback: string) {
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return v || fallback;
}
async function loadObject(url: string, ext: string): Promise<THREE.Object3D> {
const material = new THREE.MeshStandardMaterial({ color: cssColor("--blue", "#83a598"), metalness: 0.1, roughness: 0.6, side: THREE.DoubleSide });
switch (ext) {
case "glb":
case "gltf":
return (await new GLTFLoader().loadAsync(url)).scene;
case "stl": {
const geo = await new STLLoader().loadAsync(url);
geo.computeVertexNormals();
return new THREE.Mesh(geo, material);
}
case "obj": {
const obj = await new OBJLoader().loadAsync(url);
obj.traverse((c) => c instanceof THREE.Mesh && (c.material = material));
return obj;
}
default: {
// STEP / IGES: tessellate with OpenCascade in the browser.
const [occt, buf] = await Promise.all([loadOcct(), fetch(url, { credentials: "same-origin" }).then((r) => r.arrayBuffer())]);
const data = new Uint8Array(buf);
const res = ext === "igs" || ext === "iges" ? occt.ReadIgesFile(data, null) : occt.ReadStepFile(data, null);
if (!res.success) throw new Error("the file could not be read");
const group = new THREE.Group();
for (const m of res.meshes) {
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", new THREE.Float32BufferAttribute(m.attributes.position.array, 3));
if (m.attributes.normal) geo.setAttribute("normal", new THREE.Float32BufferAttribute(m.attributes.normal.array, 3));
else geo.computeVertexNormals();
if (m.index) geo.setIndex(new THREE.Uint32BufferAttribute(m.index.array, 1));
const mat = m.color ? material.clone() : material;
if (m.color) (mat as THREE.MeshStandardMaterial).color = new THREE.Color(m.color[0], m.color[1], m.color[2]);
group.add(new THREE.Mesh(geo, mat));
}
return group;
}
}
}
export function ModelViewer({ url, ext }: { url: string; ext: string }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<{ loading: boolean; error?: string; info?: string }>({ loading: true });
useEffect(() => {
const el = host.current!;
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
el.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(cssColor("--bg0", "#282828"));
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6));
const sun = new THREE.DirectionalLight(0xffffff, 1.8);
sun.position.set(3, 5, 4);
scene.add(sun);
const camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1e6);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const resize = () => {
const w = el.clientWidth || 300;
const h = el.clientHeight || 300;
renderer.setSize(w, h);
camera.aspect = w / h;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(el);
resize();
let alive = true;
let frame = 0;
const loop = () => {
if (!alive) return;
controls.update();
renderer.render(scene, camera);
frame = requestAnimationFrame(loop);
};
loop();
loadObject(url, ext)
.then((obj) => {
if (!alive) return;
scene.add(obj);
// Frame the model.
const box = new THREE.Box3().setFromObject(obj);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.length() / 2, 1e-3);
camera.position.copy(center).add(new THREE.Vector3(radius * 1.4, radius * 1.0, radius * 1.8));
camera.near = radius / 100;
camera.far = radius * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
let tris = 0;
obj.traverse((c) => {
if (c instanceof THREE.Mesh) tris += (c.geometry.index ? c.geometry.index.count : c.geometry.attributes.position.count) / 3;
});
setState({ loading: false, info: `${Math.round(tris).toLocaleString()} triangles · ${size.x.toFixed(2)} × ${size.y.toFixed(2)} × ${size.z.toFixed(2)}` });
})
.catch((e) => alive && setState({ loading: false, error: String(e?.message ?? e) }));
return () => {
alive = false;
cancelAnimationFrame(frame);
ro.disconnect();
controls.dispose();
scene.traverse((c) => {
if (c instanceof THREE.Mesh) {
c.geometry.dispose();
(Array.isArray(c.material) ? c.material : [c.material]).forEach((m) => m.dispose());
}
});
renderer.dispose();
el.removeChild(renderer.domElement);
};
}, [url, ext]);
return (
<div className="model-viewer">
<div ref={host} className="model-canvas" />
<div className="model-info">{state.loading ? "…" : state.error ? `⚠ ${state.error}` : state.info}</div>
</div>
);
}
+3 -2
View File
@@ -2,6 +2,7 @@ import { del, post } from "../api";
import { useT } from "../i18n";
import { useStore } from "../store";
import type { QueueItem } from "../types";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const EMPTY: QueueItem[] = [];
@@ -15,7 +16,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
<div className="queue-head">
<span>{busy ? t("queue.pending", { n: items.length }) : t("queue.stopped", { n: items.length })}</span>
{!busy && (
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => alert(e.message))}>
<button className="small primary" onClick={() => post(`/sessions/${sessionId}/queue/flush`).catch((e) => toast(e.message))}>
{t("queue.sendNow")}
</button>
)}
@@ -35,7 +36,7 @@ export function QueueList({ sessionId, busy }: { sessionId: string; busy: boolea
className="small ghost"
aria-label={t("queue.remove")}
title={t("queue.remove")}
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => alert(e.message))}
onClick={() => del(`/sessions/${sessionId}/queue/${it.id}`).catch((e) => toast(e.message))}
>
×
</button>
+58
View File
@@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { useT } from "../i18n";
import { confirmDialog, promptDialog, toast } from "../dialogs";
/** Rewind to a message, optionally restoring the files the agent changed since. */
export function RewindDialog({ sessionId, messageId, onDone, onClose }: { sessionId: string; messageId: string; onDone: () => void; onClose: () => void }) {
const t = useT();
const [files, setFiles] = useState<{ path: string; created: boolean }[]>();
const [restore, setRestore] = useState(true);
const [busy, setBusy] = useState(false);
useEffect(() => {
get<{ path: string; created: boolean }[]>(`/sessions/${sessionId}/changes?since_message=${messageId}`).then(setFiles).catch(() => setFiles([]));
}, [sessionId, messageId]);
const go = async () => {
setBusy(true);
try {
await post(`/sessions/${sessionId}/rewind`, { message_id: messageId, restore_files: restore && !!files?.length });
onDone();
} catch (e: any) {
toast(e.message);
setBusy(false);
}
};
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" role="dialog" aria-label={t("chat.rewind")} onClick={(e) => e.stopPropagation()}>
<h3>{t("chat.rewind")}</h3>
<p style={{ fontSize: 13, color: "var(--fg3)" }}>{t("rewind.intro")}</p>
{files === undefined ? (
<div className="empty">…</div>
) : files.length === 0 ? (
<p style={{ fontSize: 13 }}>{t("rewind.noFiles")}</p>
) : (
<>
<label className="row" style={{ marginBottom: 6 }}>
<input type="checkbox" checked={restore} onChange={(e) => setRestore(e.target.checked)} /> {t("rewind.restore", { n: files.length })}
</label>
<ul className="rewind-files">
{files.map((f) => (
<li key={f.path}>
<code>{f.path}</code> {f.created && <span className="chip">{t("rewind.willDelete")}</span>}
</li>
))}
</ul>
<p style={{ fontSize: 12, color: "var(--muted)" }}>{t("rewind.bashNote")}</p>
</>
)}
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
<button onClick={onClose}>{t("common.cancel")}</button>
<button className="primary" disabled={busy || files === undefined} onClick={go}>
{t("rewind.confirm")}
</button>
</div>
</div>
</div>
);
}
+67 -22
View File
@@ -1,16 +1,19 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";
import { get, patch as apiPatch, post, put } from "../api";
import { useFetch, useModels } from "../hooks";
import { getLang, languages, setLang, useT, type Key } from "../i18n";
import { navigate } from "../router";
import { useStore } from "../store";
import { applyTheme, getTheme, THEMES, type Theme } from "../theme";
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEME_MODES, THEMES, type Theme, type ThemeMode } from "../theme";
import { setReasoningMode, useReasoningMode, type ReasoningMode } from "../prefs";
import { ModelSelect } from "./ModelSelect";
import { Account } from "./settings/Account";
import { AgentsEditor } from "./settings/AgentsEditor";
import { Folders } from "./settings/Folders";
import { Users } from "./settings/Users";
import { Audit } from "./settings/Audit";
import { Tools } from "./settings/Tools";
import { confirmDialog, promptDialog, toast } from "../dialogs";
type Json = any;
interface ConfigResponse {
@@ -48,7 +51,9 @@ function useSaver(reload: () => void) {
function InterfaceSettings() {
const t = useT();
const reasoning = useReasoningMode();
const [theme, setThemeState] = useState<Theme>(getTheme());
const theme = useSyncExternalStore(onThemeChange, getTheme);
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const themeInfo = THEMES.find((th) => th.id === theme)!;
const [notif, setNotif] = useState(typeof Notification !== "undefined" ? Notification.permission : "denied");
return (
<div className="panel">
@@ -63,20 +68,36 @@ function InterfaceSettings() {
))}
</select>
<label>{t("settings.theme")}</label>
<select
value={theme}
onChange={(e) => {
const v = e.target.value as Theme;
setThemeState(v);
applyTheme(v);
}}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{th.id === "system" ? t("settings.themeSystem") : th.name}
</option>
<div className="stack">
<select
value={theme}
onChange={(e) => {
const v = e.target.value as Theme;
applyTheme(v, mode);
}}
>
{THEMES.map((th) => (
<option key={th.id} value={th.id}>
{th.name}
</option>
))}
</select>
<div className="hint">{t("settings.themeSource", { source: themeInfo.source, license: themeInfo.license })}</div>
</div>
<label>{t("settings.themeMode")}</label>
<div className="segmented">
{THEME_MODES.map((m) => (
<button
key={m}
className={`small ${m === mode ? "active" : "ghost"}`}
onClick={() => {
applyTheme(theme, m);
}}
>
{t(m === "auto" ? "settings.themeAuto" : m === "light" ? "settings.themeLight" : "settings.themeDark")}
</button>
))}
</select>
</div>
<label>{t("settings.reasoningDisplay")}</label>
<select value={reasoning} onChange={(e) => setReasoningMode(e.target.value as ReasoningMode)}>
<option value="collapsed">{t("settings.reasoningCollapsed")}</option>
@@ -114,6 +135,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
monthly_budget: s.monthly_budget ?? 0,
budget_warning: s.budget_warning ?? 0.8,
images_budget: cfg.config.images?.monthly_budget ?? "",
budget_action: s.budget_action ?? "warn",
sandbox_mode: cfg.config.sandbox?.mode ?? "auto",
clean_room: c.clean_room ?? true,
chromium: c.chromium ?? "chromium",
widths: (c.widths ?? []).join(", "),
@@ -134,6 +157,8 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
{ path: ["server", "max_parallel_subagents"], value: Number(form.max_parallel_subagents) },
{ path: ["server", "monthly_budget"], value: Number(form.monthly_budget) || 0 },
{ path: ["server", "budget_warning"], value: Number(form.budget_warning) || 0.8 },
{ path: ["server", "budget_action"], value: form.budget_action },
{ path: ["sandbox", "mode"], value: form.sandbox_mode },
{ path: ["images", "monthly_budget"], value: form.images_budget === "" ? null : Number(form.images_budget) },
{ path: ["craft", "clean_room"], value: form.clean_room },
{ path: ["craft", "chromium"], value: form.chromium },
@@ -168,6 +193,18 @@ function General({ cfg, reload }: { cfg: ConfigResponse; reload: () => void }) {
<div className="hint">{t("settings.monthlyBudgetHint")}</div>
<label>{t("settings.budgetWarning")}</label>
<input type="number" min={0} max={1} step={0.05} value={form.budget_warning} onChange={(e) => setForm({ ...form, budget_warning: e.target.value })} />
<label>{t("settings.budgetAction")}</label>
<select value={form.budget_action} onChange={(e) => setForm({ ...form, budget_action: e.target.value })}>
<option value="warn">{t("settings.budgetWarn")}</option>
<option value="block">{t("settings.budgetBlock")}</option>
</select>
<label>{t("settings.sandbox")}</label>
<select value={form.sandbox_mode} onChange={(e) => setForm({ ...form, sandbox_mode: e.target.value })}>
<option value="auto">{t("settings.sandboxAuto")}</option>
<option value="always">{t("settings.sandboxAlways")}</option>
<option value="off">{t("settings.sandboxOff")}</option>
</select>
<div className="hint">{t("settings.sandboxHint")}</div>
<label>{t("settings.maxParallelSubagents")}</label>
<input type="number" min={0} value={form.max_parallel_subagents} onChange={(e) => setForm({ ...form, max_parallel_subagents: e.target.value })} />
<div className="hint">{t("settings.maxParallelSubagentsHint")}</div>
@@ -305,8 +342,8 @@ function ProviderEditor({ name, p, reload }: { name: string; p: Json; reload: ()
return savePatches(patches);
});
const remove = () => {
if (!confirm(t("settings.deleteProviderConfirm", { name }))) return;
const remove = async () => {
if (!(await confirmDialog({ message: t("settings.deleteProviderConfirm", { name }), danger: true, confirmLabel: t("common.delete") }))) return;
void run(() => savePatches([{ path: ["providers", name], value: null }]));
};
@@ -469,7 +506,7 @@ function Providers({ cfg, reload }: { cfg: ConfigResponse; reload: () => void })
const add = () => {
const n = name.trim() || preset;
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const p = PRESETS[preset];
const value: Record<string, unknown> = { kind: p.kind, base_url: p.base_url, api_key: p.api_key, enabled: true, models: [] };
if (p.auth_prefix) value.auth_prefix = p.auth_prefix;
@@ -534,15 +571,18 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
headers: mapToLines(m.headers, ": "),
enabled: m.enabled ?? true,
load: m.load ?? "auto",
users: (m.users ?? []).join(", "),
});
const [open, setOpen] = useState(!!isNew);
const { run, view } = useSaver(reload);
const save = () => {
const n = form.name.trim();
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const value: Record<string, unknown> = { type: form.type, enabled: form.enabled };
if (form.load !== "auto") value.load = form.load;
const users = form.users.split(/[\s,]+/).filter(Boolean);
if (users.length) value.users = users;
if (form.type === "stdio") {
value.command = form.command;
value.args = form.args.split("\n").map((s: string) => s.trim()).filter(Boolean);
@@ -612,6 +652,9 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
<option value="on_demand">{t("mcp.loadOnDemand")}</option>
</select>
<div className="hint">{t("mcp.loadHint")}</div>
<label>{t("mcp.users")}</label>
<input value={form.users} onChange={(e) => setForm({ ...form, users: e.target.value })} placeholder={t("mcp.usersPlaceholder")} />
<div className="hint">{t("mcp.usersHint")}</div>
</div>
{status?.tools?.length ? (
<details style={{ marginTop: 10 }}>
@@ -632,7 +675,7 @@ function McpEditor({ name, m, reload, isNew }: { name: string; m: Json; reload:
</button>
<span className="spacer" />
{!isNew && (
<button className="danger" onClick={() => confirm(t("settings.deleteMcpConfirm", { name })) && run(() => savePatches([{ path: ["mcp", name], value: null }]))}>
<button className="danger" onClick={() => void confirmDialog({ message: t("settings.deleteMcpConfirm", { name }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => savePatches([{ path: ["mcp", name], value: null }])))}>
{t("common.delete")}
</button>
)}
@@ -773,7 +816,7 @@ function Raw({ reload }: { reload: () => void }) {
// --- page -------------------------------------------------------------------
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "account", "raw"] as const;
const ADMIN_TABS = ["general", "providers", "agents", "mcp", "permissions", "users", "folders", "tools", "audit", "account", "raw"] as const;
const USER_TABS = ["general", "account"] as const;
export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void }) {
@@ -808,6 +851,8 @@ export function Settings({ tab, onMenu }: { tab?: string; onMenu: () => void })
{active === "account" && <Account />}
{cfg && active === "users" && <Users />}
{cfg && active === "folders" && <Folders />}
{cfg && active === "audit" && <Audit />}
{cfg && active === "tools" && <Tools />}
{cfg && active === "general" && <General key={JSON.stringify(cfg.config.server)} cfg={cfg} reload={reload} />}
{cfg && active === "providers" && <Providers cfg={cfg} reload={reload} />}
{cfg && active === "mcp" && <Mcp cfg={cfg} reload={reload} />}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useState } from "react";
import { get, post } from "../api";
import { toast } from "../dialogs";
import { fmtRelative, useT } from "../i18n";
import { Spinner } from "./Activity";
interface ShellInfo {
id: string;
command: string;
started_at: number;
running: boolean;
exit_code: number | null;
output: string;
}
/** Header chip with the session's background shells; opens a live output popup. */
export function ShellsChip({ sessionId, busy }: { sessionId: string; busy: boolean }) {
const t = useT();
const [shells, setShells] = useState<ShellInfo[]>([]);
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string>();
useEffect(() => {
let alive = true;
const load = () => get<ShellInfo[]>(`/sessions/${sessionId}/shells`).then((s) => alive && setShells(s)).catch(() => undefined);
void load();
const anyRunning = shells.some((s) => s.running);
if (!open && !busy && !anyRunning) return () => void (alive = false);
const h = setInterval(load, open ? 1500 : 5000);
return () => {
alive = false;
clearInterval(h);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, open, busy, shells.some((s) => s.running)]);
const running = shells.filter((s) => s.running).length;
if (shells.length === 0) return null;
const current = shells.find((s) => s.id === selected) ?? shells[0];
return (
<>
<button className={`chip ${running ? "warn" : ""}`} style={{ border: "none" }} onClick={() => setOpen(true)} title={t("shells.title")}>
{running ? <Spinner small /> : "▣"} {t("shells.chip", { n: running, total: shells.length })}
</button>
{open && (
<div className="modal-backdrop" onClick={() => setOpen(false)}>
<div className="modal wide" role="dialog" aria-label={t("shells.title")} onClick={(e) => e.stopPropagation()}>
<div className="row" style={{ marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{t("shells.title")}</h3>
<span className="spacer" />
<button className="small ghost" onClick={() => setOpen(false)} aria-label={t("common.close")}>
✕
</button>
</div>
<div className="mode-tabs" style={{ flexWrap: "wrap", marginBottom: 8 }}>
{shells.map((s) => (
<button key={s.id} className={s.id === current.id ? "active" : ""} onClick={() => setSelected(s.id)}>
{s.running ? "● " : "○ "}
{s.command.slice(0, 40)}
</button>
))}
</div>
<div className="row" style={{ fontSize: 12.5, color: "var(--muted)", marginBottom: 6 }}>
<code>{current.command}</code>
<span>· {fmtRelative(current.started_at)}</span>
<span>· {current.running ? t("shells.running") : t("shells.exited", { code: current.exit_code ?? "?" })}</span>
<span className="spacer" />
{current.running && (
<button className="small danger" onClick={() => post(`/sessions/${sessionId}/shells/${current.id}/kill`).catch(toast)}>
■ {t("shells.stop")}
</button>
)}
</div>
<pre className="shell-output">{current.output || t("shells.noOutput")}</pre>
</div>
</div>
)}
</>
);
}
+16 -1
View File
@@ -1,4 +1,5 @@
import { useMemo } from "react";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
import { useMemo, useSyncExternalStore } from "react";
import { post } from "../api";
import { fmtRelative, useT, type Key } from "../i18n";
import { navigate } from "../router";
@@ -88,6 +89,7 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
{me?.display_name || me?.username}
{me?.role === "admin" && <span className="chip" style={{ marginLeft: 6 }}>admin</span>}
</span>
<ThemeModeButton />
<button className="small ghost" onClick={() => post("/auth/logout").then(() => location.reload())}>
{t("nav.logout")}
</button>
@@ -109,3 +111,16 @@ export function Sidebar({ open, activeId, section }: { open: boolean; activeId?:
</aside>
);
}
/** Quick light/dark/auto switcher. */
function ThemeModeButton() {
const t = useT();
const mode = useSyncExternalStore(onThemeChange, getThemeMode);
const icon = mode === "auto" ? "◐" : mode === "light" ? "☀" : "☾";
const label = t(mode === "auto" ? "settings.themeAuto" : mode === "light" ? "settings.themeLight" : "settings.themeDark");
return (
<button className="small ghost theme-toggle" onClick={cycleThemeMode} title={`${t("settings.themeToggle")} – ${label}`} aria-label={t("settings.themeToggle")}>
{icon}
</button>
);
}
+22 -1
View File
@@ -4,6 +4,7 @@ import { useStore } from "../store";
import type { Part, Todo } from "../types";
import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { IMAGE_EXT, projectFileUrl } from "./Markdown";
type ToolCall = Extract<Part, { type: "tool_call" }>;
type ToolResult = Extract<Part, { type: "tool_result" }>;
@@ -57,7 +58,7 @@ export function TodoList({ todos }: { todos: Todo[] }) {
);
}
export function ToolCard({ call, result, running }: { call: ToolCall; result?: ToolResult; running: boolean }) {
export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; result?: ToolResult; running: boolean; baseDir?: string }) {
const t = useT();
const progress = useStore((s) => s.progress[call.id]);
const status = result ? (result.is_error ? "err" : "ok") : running ? "run" : "err";
@@ -77,6 +78,26 @@ export function ToolCard({ call, result, running }: { call: ToolCall; result?: T
);
}
// Image files written by the agent are shown as images (SVG too, rendered via <img>).
const writtenImage =
(call.name === "write" || call.name === "edit") && typeof call.input?.path === "string" && IMAGE_EXT.test(call.input.path) && result && !result.is_error
? projectFileUrl(call.input.path, baseDir)
: undefined;
if (writtenImage) {
return (
<div className="tool image-result">
<div className="tool-head">
<span className="tool-name">{call.name}</span>
<span className="tool-title">{call.input.path}</span>
<span className="status ok">✓</span>
</div>
<a href={writtenImage} target="_blank" rel="noreferrer">
<img src={`${writtenImage}&v=${result!.call_id}`} alt={call.input.path} />
</a>
</div>
);
}
// Short diffs are shown right away, like in a terminal agent.
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
const openByDefault =
+199
View File
@@ -0,0 +1,199 @@
import { useEffect } from "react";
import { useFetch, useModels } from "../hooks";
import { fmtCompact, fmtCost, fmtDate, fmtNumber, fmtPercent, useT } from "../i18n";
import { useStore } from "../store";
import type { Aggregate, Session } from "../types";
interface SessionStats {
total: Aggregate;
by_model: (Aggregate & { model: string })[];
by_agent: (Aggregate & { key: string })[];
first: number | null;
last: number | null;
currency: string;
}
interface BudgetRow {
provider: string;
budget: number | null;
spent: number;
share: number | null;
warning: boolean;
}
function Row({ label, value, hint }: { label: string; value: React.ReactNode; hint?: string }) {
return (
<div className="kv" title={hint}>
<span>{label}</span>
<b>{value}</b>
</div>
);
}
const perMillion = (a: Aggregate) => {
const tokens = a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens;
return tokens > 0 ? (a.cost / tokens) * 1_000_000 : 0;
};
/** Everything about the models used in a session: configuration, usage, speed, cost. */
export function UsageOverlay({ session, onClose }: { session: Session; onClose: () => void }) {
const t = useT();
const { data } = useFetch<SessionStats>(`/sessions/${session.id}/stats`);
const models = useModels();
const isAdmin = useStore((s) => s.me?.role === "admin");
const budget = useFetch<{ providers: BudgetRow[]; currency: string }>(isAdmin ? "/budget" : null).data;
useEffect(() => {
const h = (e: KeyboardEvent) => e.key === "Escape" && onClose();
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
}, [onClose]);
const cur = data?.currency ?? "USD";
const info = (spec: string) => {
const [p, ...rest] = spec.split("/");
return models.find((x) => x.provider === p)?.models.find((m) => m.id === rest.join("/"));
};
const u = session.last_usage;
const ctxTokens = u ? u.input_tokens + u.cache_read_tokens + u.cache_write_tokens + u.output_tokens : 0;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal wide" role="dialog" aria-label={t("usage.title")} onClick={(e) => e.stopPropagation()}>
<div className="row" style={{ marginBottom: 8 }}>
<h3 style={{ margin: 0 }}>{t("usage.title")}</h3>
<span className="spacer" />
<button className="small ghost" onClick={onClose} aria-label={t("common.close")}>
✕
</button>
</div>
{!data ? (
<div className="empty">{t("common.loading")}</div>
) : (
<>
<div className="kpis">
<div className="kpi">
<div className="k-label">{t("stats.cost")}</div>
<div className="k-value">{fmtCost(data.total.cost, cur)}</div>
<div className="k-sub">{t("stats.perRequest", { v: fmtCost(data.total.requests ? data.total.cost / data.total.requests : 0, cur) })}</div>
</div>
<div className="kpi">
<div className="k-label">{t("stats.requests")}</div>
<div className="k-value">{fmtNumber(data.total.requests)}</div>
{data.total.errors > 0 && <div className="k-sub">{t("stats.errors", { n: data.total.errors })}</div>}
</div>
<div className="kpi">
<div className="k-label">{t("stats.tokens")}</div>
<div className="k-value">
{fmtCompact(data.total.input_tokens + data.total.cache_read_tokens + data.total.cache_write_tokens)}↑ {fmtCompact(data.total.output_tokens)}↓
</div>
<div className="k-sub">
{t("stats.cacheHit")}: {fmtPercent(data.total.cache_hit_rate)}
</div>
</div>
<div className="kpi">
<div className="k-label">{t("usage.period")}</div>
<div className="k-value" style={{ fontSize: 15 }}>
{data.first ? fmtDate(data.first) : "–"}
</div>
<div className="k-sub">{data.last ? `→ ${fmtDate(data.last)}` : ""}</div>
</div>
</div>
{data.by_model.map((m) => {
const mi = info(m.model);
const provider = m.model.split("/")[0];
const b = budget?.providers.find((x) => x.provider === provider);
const isCurrent = m.model === session.model;
return (
<div className="panel" key={m.model}>
<h3>
{mi?.name ?? m.model}
{isCurrent && <span className="chip">{t("usage.current")}</span>}
<span className="chip">{provider}</span>
</h3>
<div className="kv-grid">
<div>
<div className="kv-title">{t("usage.model")}</div>
<Row label="ID" value={<code>{m.model}</code>} />
<Row label={t("settings.context")} value={mi?.context ? `${fmtCompact(mi.context)} tok` : "–"} />
<Row label={t("settings.maxOutput")} value={mi?.max_output ? `${fmtCompact(mi.max_output)} tok` : "–"} />
<Row label={t("settings.reasoning")} value={mi?.reasoning ? `✓${mi.effort ? ` (${mi.effort})` : ""}` : "–"} />
<Row label={t("settings.vision")} value={mi?.vision ? "✓" : "–"} />
{isCurrent && mi?.context ? (
<Row label={t("usage.contextNow")} value={`${fmtCompact(ctxTokens)} / ${fmtCompact(mi.context)} (${fmtPercent(ctxTokens / mi.context)})`} />
) : null}
</div>
<div>
<div className="kv-title">{t("usage.prices")}</div>
<Row label={t("settings.priceIn")} value={mi?.input_price != null ? fmtCost(mi.input_price, cur) : "–"} />
<Row label={t("settings.priceOut")} value={mi?.output_price != null ? fmtCost(mi.output_price, cur) : "–"} />
<Row label={t("settings.priceCacheRead")} value={mi?.cache_read_price != null ? fmtCost(mi.cache_read_price, cur) : "–"} />
<Row label={t("settings.priceCacheWrite")} value={mi?.cache_write_price != null ? fmtCost(mi.cache_write_price, cur) : "–"} />
<Row label={t("stats.perMillion")} value={fmtCost(perMillion(m), cur)} hint={t("stats.perMillionHint")} />
</div>
<div>
<div className="kv-title">{t("usage.usage")}</div>
<Row label={t("stats.requests")} value={fmtNumber(m.requests)} />
<Row label={t("stats.input")} value={fmtNumber(m.input_tokens)} />
<Row label={t("stats.cacheRead")} value={fmtNumber(m.cache_read_tokens)} />
<Row label={t("usage.cacheWrite")} value={fmtNumber(m.cache_write_tokens)} />
<Row label={t("stats.output")} value={fmtNumber(m.output_tokens)} />
<Row label={t("stats.cacheHit")} value={fmtPercent(m.cache_hit_rate)} />
</div>
<div>
<div className="kv-title">{t("usage.speed")}</div>
<Row label={t("stats.ttft")} value={`${fmtNumber(m.avg_ttft_ms)} ms`} />
<Row label={t("stats.prefillSpeed")} value={`${fmtNumber(m.prefill_tps)} tok/s`} />
<Row label={t("stats.outputSpeed")} value={`${fmtNumber(m.output_tps, 1)} tok/s`} />
<Row label={t("usage.totalTime")} value={`${fmtNumber(m.duration_ms / 1000, 1)} s`} />
<div className="kv-title" style={{ marginTop: 8 }}>{t("stats.cost")}</div>
<Row label={t("usage.inSession")} value={fmtCost(m.cost, cur)} />
{b && (
<Row
label={t("usage.providerMonth")}
value={
<span style={{ color: b.warning ? "var(--accent2)" : undefined }}>
{fmtCost(b.spent, budget!.currency)}
{b.budget ? ` / ${fmtCost(b.budget, budget!.currency)}` : ""}
</span>
}
/>
)}
</div>
</div>
</div>
);
})}
{data.by_agent.length > 1 && (
<div className="panel">
<h3>{t("usage.byAgent")}</h3>
<table className="grid">
<thead>
<tr>
<th>{t("stats.agent")}</th>
<th className="num">{t("stats.requests")}</th>
<th className="num">{t("stats.tokens")}</th>
<th className="num">{t("stats.cost")}</th>
</tr>
</thead>
<tbody>
{data.by_agent.map((a) => (
<tr key={a.key}>
<td>{a.key}</td>
<td className="num">{fmtNumber(a.requests)}</td>
<td className="num">{fmtCompact(a.input_tokens + a.cache_read_tokens + a.cache_write_tokens + a.output_tokens)}</td>
<td className="num">{fmtCost(a.cost, cur)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)}
</div>
</div>
);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { AgentInfo } from "../../types";
import { ModelSelect } from "../ModelSelect";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
type Patch = { path: string[]; value: unknown };
@@ -45,7 +46,7 @@ function AgentRow({ a, raw, onSaved }: { a: AgentInfo; raw: any; onSaved: (ok: b
};
const reset = async () => {
if (!confirm(t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }))) return;
if (!(await confirmDialog({ message: t(builtin ? "agents.resetConfirm" : "agents.deleteConfirm", { name: a.name }), danger: true }))) return;
try {
await patch("/config", { patches: [{ path: ["agents", a.name], value: null }] });
onSaved(true, t("settings.saved"));
@@ -135,7 +136,7 @@ export function AgentsEditor({ rawAgents, reloadConfig }: { rawAgents: Record<st
const add = async (template: "writer" | "blank") => {
const n = name.trim() || (template === "writer" ? "writer" : "");
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return alert(t("settings.invalidName"));
if (!/^[a-zA-Z0-9_-]+$/.test(n)) return toast(t("settings.invalidName"));
const value =
template === "writer"
? { mode: "subagent", description: "Writes documentation, summaries, guides and translations.", tools: ["read", "glob", "grep"], max_parallel: 1, prompt: WRITER_PROMPT }
+73
View File
@@ -0,0 +1,73 @@
import { useState } from "react";
import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
interface Entry {
ts: number;
user: string;
action: string;
detail: string;
ip: string;
}
/** Admin: audit log and backup download. */
export function Audit() {
const t = useT();
const [filter, setFilter] = useState("");
const { data, reload } = useFetch<Entry[]>(`/audit?limit=500${filter ? `&action=${encodeURIComponent(filter)}` : ""}`);
return (
<>
<div className="panel">
<h3>{t("audit.backup")}</h3>
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 8 }}>{t("audit.backupHint")}</div>
<a className="button primary" href="/api/backup">
{t("audit.download")}
</a>
</div>
<div className="panel">
<h3>
{t("audit.title")}
<span className="spacer" />
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="">{t("audit.all")}</option>
{["login", "permission", "config", "user", "folder", "session", "git", "backup"].map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
<button className="small" onClick={reload}>
↻
</button>
</h3>
<div style={{ overflowX: "auto" }}>
<table className="grid">
<thead>
<tr>
<th>{t("audit.time")}</th>
<th>{t("stats.user")}</th>
<th>{t("audit.action")}</th>
<th>{t("audit.detail")}</th>
<th>IP</th>
</tr>
</thead>
<tbody>
{data?.map((e, i) => (
<tr key={i} className={e.action.includes("failed") || e.action.includes("blocked") || e.action.includes("reject") ? "audit-warn" : ""}>
<td style={{ whiteSpace: "nowrap" }}>{fmtDate(e.ts)}</td>
<td>{e.user || "–"}</td>
<td>
<code>{e.action}</code>
</td>
<td style={{ wordBreak: "break-word" }}>{e.detail}</td>
<td>{e.ip}</td>
</tr>
))}
</tbody>
</table>
{data?.length === 0 && <div className="empty">–</div>}
</div>
</div>
</>
);
}
+2 -1
View File
@@ -3,6 +3,7 @@ import { del, post, put } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import type { User } from "../../types";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
interface Folder {
id: string;
@@ -44,7 +45,7 @@ export function Folders() {
<h3>
📁 <code>{f.path.slice(ws.length) || "/"}</code>
<span className="spacer" />
<button className="small danger" onClick={() => confirm(t("folders.removeConfirm")) && run(() => del(`/folders/${f.id}`))}>
<button className="small danger" onClick={() => void confirmDialog({ message: t("folders.removeConfirm"), danger: true, confirmLabel: t("folders.remove") }).then((ok): unknown => ok && run(() => del(`/folders/${f.id}`)))}>
{t("folders.remove")}
</button>
</h3>
+42
View File
@@ -0,0 +1,42 @@
import { useEffect } from "react";
import { post } from "../../api";
import { useFetch } from "../../hooks";
import { useT } from "../../i18n";
import { Spinner } from "../Activity";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
interface ToolsStatus {
ghidra: { installed: boolean; version?: string | null; running: boolean; log: string };
}
/** Admin: optional tools installed into the data volume. */
export function Tools() {
const t = useT();
const { data, reload } = useFetch<ToolsStatus>("/tools");
// Poll while an installation runs.
useEffect(() => {
if (!data?.ghidra.running) return;
const h = setInterval(reload, 3000);
return () => clearInterval(h);
}, [data?.ghidra.running, reload]);
const g = data?.ghidra;
return (
<div className="panel">
<h3>
Ghidra
{g?.installed && <span className="chip">{g.version ?? t("tools.installed")}</span>}
</h3>
<div style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 10 }}>{t("tools.ghidraHint")}</div>
{g?.running ? (
<div className="row">
<Spinner /> {t("tools.installing")}
</div>
) : (
<button className="primary" onClick={() => post("/tools/ghidra/install").then(reload).catch((e) => toast(e.message))}>
{g?.installed ? t("tools.reinstall") : t("tools.install")}
</button>
)}
{g?.log && <pre className="git-output" style={{ marginTop: 10 }}>{g.log}</pre>}
</div>
);
}
+18 -3
View File
@@ -4,6 +4,7 @@ import { useFetch } from "../../hooks";
import { fmtDate, useT } from "../../i18n";
import { useStore } from "../../store";
import type { User } from "../../types";
import { confirmDialog, promptDialog, toast } from "../../dialogs";
export function Users() {
const t = useT();
@@ -22,8 +23,8 @@ export function Users() {
}
};
const resetPassword = (u: User) => {
const pw = prompt(t("users.newPasswordFor", { name: u.username }));
const resetPassword = async (u: User) => {
const pw = await promptDialog({ message: t("users.newPasswordFor", { name: u.username }), password: true });
if (pw) void run(() => patch(`/users/${u.id}`, { password: pw }));
};
@@ -39,6 +40,7 @@ export function Users() {
<th>{t("users.displayName")}</th>
<th>{t("users.role")}</th>
<th>{t("users.login")}</th>
<th>{t("users.quota")}</th>
<th>{t("users.created")}</th>
<th />
</tr>
@@ -60,6 +62,19 @@ export function Users() {
<td>
{u.has_password && <span className="chip">{t("users.password")}</span>} {u.oidc && <span className="chip">SSO</span>}
</td>
<td>
<button
className="small ghost"
title={t("users.quotaHint")}
onClick={async () => {
const v = await promptDialog({ message: t("users.quotaPrompt", { name: u.username }), defaultValue: u.monthly_quota != null ? String(u.monthly_quota) : "" });
if (v === null) return;
void run(() => patch(`/users/${u.id}`, { monthly_quota: v.trim() === "" ? -1 : Number(v.replace(",", ".")) }));
}}
>
{u.monthly_quota != null ? u.monthly_quota : "∞"}
</button>
</td>
<td>{fmtDate(u.created_at, false)}</td>
<td>
<div className="row" style={{ flexWrap: "nowrap" }}>
@@ -70,7 +85,7 @@ export function Users() {
{u.disabled ? t("users.enable") : t("users.disable")}
</button>
{u.id !== me?.id && (
<button className="small danger" onClick={() => confirm(t("users.deleteConfirm", { name: u.username })) && run(() => del(`/users/${u.id}`))}>
<button className="small danger" onClick={() => void confirmDialog({ message: t("users.deleteConfirm", { name: u.username }), danger: true, confirmLabel: t("common.delete") }).then((ok): unknown => ok && run(() => del(`/users/${u.id}`)))}>
{t("common.delete")}
</button>
)}
+142
View File
@@ -0,0 +1,142 @@
// In-app dialogs and toasts instead of the browser's alert/confirm/prompt.
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { t } from "./i18n";
interface ConfirmOpts {
title?: string;
message: string;
confirmLabel?: string;
/** Destructive action: red button. */
danger?: boolean;
}
interface PromptOpts {
title?: string;
message: string;
defaultValue?: string;
placeholder?: string;
password?: boolean;
confirmLabel?: string;
}
type Dialog =
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
interface Toast {
id: number;
message: string;
kind: "error" | "info" | "success";
}
let dialogs: Dialog[] = [];
let toasts: Toast[] = [];
let seq = 0;
const listeners = new Set<() => void>();
const emit = () => listeners.forEach((l) => l());
const subscribe = (l: () => void) => (listeners.add(l), () => listeners.delete(l));
export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "confirm", id: ++seq, resolve, ...o }];
emit();
});
}
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
const o = typeof opts === "string" ? { message: opts } : opts;
return new Promise((resolve) => {
dialogs = [...dialogs, { kind: "prompt", id: ++seq, resolve, ...o }];
emit();
});
}
export function toast(message: unknown, kind: Toast["kind"] = "error") {
const text = message instanceof Error ? message.message : typeof message === "string" ? message : String((message as any)?.message ?? message);
const id = ++seq;
toasts = [...toasts, { id, message: text, kind }];
emit();
setTimeout(() => {
toasts = toasts.filter((x) => x.id !== id);
emit();
}, kind === "error" ? 7000 : 3500);
}
function close(d: Dialog, value: any) {
dialogs = dialogs.filter((x) => x.id !== d.id);
emit();
(d.resolve as (v: any) => void)(value);
}
function DialogView({ d }: { d: Dialog }) {
const [value, setValue] = useState(d.kind === "prompt" ? d.defaultValue ?? "" : "");
const input = useRef<HTMLInputElement>(null);
const ok = useRef<HTMLButtonElement>(null);
const cancelValue = d.kind === "confirm" ? false : null;
useEffect(() => {
if (d.kind === "prompt") {
input.current?.focus();
input.current?.select();
} else ok.current?.focus();
const h = (e: KeyboardEvent) => e.key === "Escape" && close(d, cancelValue);
window.addEventListener("keydown", h);
return () => window.removeEventListener("keydown", h);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const submit = () => close(d, d.kind === "confirm" ? true : value);
return (
<div className="modal-backdrop" onClick={() => close(d, cancelValue)}>
<form
className="modal dialog"
role={d.kind === "confirm" ? "alertdialog" : "dialog"}
aria-label={d.title ?? d.message}
onClick={(e) => e.stopPropagation()}
onSubmit={(e) => (e.preventDefault(), submit())}
>
{d.title && <h3>{d.title}</h3>}
<p className="dialog-message">{d.message}</p>
{d.kind === "prompt" && (
<input
ref={input}
type={d.password ? "password" : "text"}
value={value}
placeholder={d.placeholder}
onChange={(e) => setValue(e.target.value)}
autoComplete={d.password ? "new-password" : "off"}
style={{ width: "100%" }}
/>
)}
<div className="row dialog-actions">
<button type="button" onClick={() => close(d, cancelValue)}>
{t("common.cancel")}
</button>
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
{d.confirmLabel ?? t("common.ok")}
</button>
</div>
</form>
</div>
);
}
/** Renders open dialogs and toasts; mounted once in App. */
export function DialogHost() {
const state = useSyncExternalStore(subscribe, () => dialogs);
const ts = useSyncExternalStore(subscribe, () => toasts);
return (
<>
{state.map((d) => (
<DialogView key={d.id} d={d} />
))}
<div className="toasts" role="status" aria-live="polite">
{ts.map((x) => (
<div key={x.id} className={`toast ${x.kind}`} onClick={() => ((toasts = toasts.filter((y) => y.id !== x.id)), emit())}>
{x.kind === "error" ? "⚠ " : x.kind === "success" ? "✓ " : ""}
{x.message}
</div>
))}
</div>
</>
);
}
+75 -2
View File
@@ -125,6 +125,74 @@ export const de: Messages = {
"git.noDiff": "Keine Unterschiede.",
"composer.attachedFiles": "Angehängte Dateien (im Projekt, mit deinen Tools lesen):",
"composer.uploading": "Lade {n} hoch…",
"usage.title": "Modelle und Verbrauch dieser Session",
"usage.period": "Zeitraum",
"usage.current": "aktuell",
"usage.model": "Modell",
"usage.prices": "Preise pro 1 Mio. Tokens",
"usage.usage": "Nutzung",
"usage.speed": "Geschwindigkeit",
"usage.cacheWrite": "Cache geschrieben",
"usage.contextNow": "Kontext jetzt",
"usage.totalTime": "Gesamtzeit",
"usage.inSession": "In dieser Session",
"usage.providerMonth": "Provider diesen Monat",
"usage.byAgent": "Nach Agent und Modell",
"preview.page": "Seite {n} / {total}",
"preview.openBrowser": "Im Browser öffnen",
"preview.moreRows": "{n} weitere Zeilen nicht angezeigt",
"preview.moduleHint": "Tracker-Modul – wird auf dem Server zu Audio gerendert (der erste Start kann kurz dauern).",
"activity.phrases": "Grübelt|Tüftelt|Werkelt|Brütet|Sinniert|Knobelt|Schmiedet|Rätselt|Zaubert|Bastelt|Puzzelt|Feilt|Köchelt|Hirnt|Fummelt|Sortiert Gedanken|Dreht Schrauben|Entwirrt|Klamüsert|Ausbaldowert|Fuchst sich rein|Wuselt|Tüftelt weiter|Kalibriert",
"activity.prefill": "Prefill",
"activity.generated": "generiert",
"activity.prefilling": "liest Prompt…",
"mcp.users": "Erlaubte Benutzer",
"mcp.usersPlaceholder": "nur Admins",
"mcp.usersHint": "Benutzernamen mit Komma getrennt oder * für alle. Admins haben immer Zugriff.",
"settings.budgetAction": "Wenn ein Budget aufgebraucht ist",
"settings.budgetWarn": "Nur warnen",
"settings.budgetBlock": "Neue Anfragen blockieren",
"settings.sandbox": "Shell-Sandbox",
"settings.sandboxAuto": "Benutzer (nicht Admins)",
"settings.sandboxAlways": "Alle",
"settings.sandboxOff": "Aus",
"settings.sandboxHint": "Shell-Befehle und Git laufen in einer bubblewrap-Sandbox, in der nur die Ordner des Benutzers beschreibbar sind.",
"users.quota": "Monatskontingent",
"users.quotaHint": "Monatliches Kostenlimit (leer = unbegrenzt)",
"users.quotaPrompt": "Monatliches Kostenlimit für {name} (leer = unbegrenzt):",
"settings.tab.audit": "Audit",
"audit.title": "Audit-Log",
"audit.all": "Alle Aktionen",
"audit.time": "Zeit",
"audit.action": "Aktion",
"audit.detail": "Details",
"audit.backup": "Sicherung",
"audit.backupHint": "ZIP mit der Konfiguration und einer konsistenten Kopie der Datenbank (Sessions, Benutzer, Statistik). Als ${VAR} referenzierte Secrets sind nicht enthalten.",
"audit.download": "Sicherung herunterladen",
"craft.mode": "Craft-Modus",
"craft.modeHint": "Clean Room: nur, was ein Benutzer sieht. Deep: auch Seitenquelltext und Programme (Ghidra) – zum Nachbilden, nicht zum Kopieren.",
"craft.cleanRoom": "Clean Room",
"craft.deep": "Deep",
"settings.tab.tools": "Werkzeuge",
"tools.installed": "installiert",
"tools.install": "Ghidra installieren",
"tools.reinstall": "Installation prüfen / reparieren",
"tools.installing": "Installiere (Download ca. 800 MB, auf ARM wird der Dekompiler kompiliert – das dauert einige Minuten)…",
"tools.ghidraHint": "Mit Ghidra können Agenten im Craft-Deep-Modus Programme dekompilieren (Windows .exe/.dll, Linux, DOS …), um zu verstehen, wie sie funktionieren. Wird samt eigenem JDK ins Daten-Volume installiert.",
"rewind.intro": "Die Unterhaltung wird auf den Stand vor dieser Nachricht zurückgesetzt; du kannst sie bearbeiten und neu senden.",
"rewind.noFiles": "Der Agent hat seitdem keine Dateien geändert.",
"rewind.restore": "Auch die {n} vom Agenten geänderte(n) Datei(en) zurücksetzen",
"rewind.willDelete": "wird gelöscht (neu)",
"rewind.bashNote": "Änderungen durch Shell-Befehle (z. B. installierte Pakete) werden nicht zurückgesetzt.",
"rewind.confirm": "Zurückspulen",
"files.discard": "Verwerfen",
"files.overwrite": "Überschreiben",
"shells.title": "Hintergrund-Shells",
"shells.chip": "{n}/{total} Shells",
"shells.running": "läuft",
"shells.exited": "beendet ({code})",
"shells.stop": "Stoppen",
"shells.noOutput": "(noch keine Ausgabe)",
"login.prompt": "Melde dich an, um fortzufahren.",
"login.invalid": "Ungültiges Token.",
"login.submit": "Anmelden",
@@ -173,7 +241,6 @@ export const de: Messages = {
"chat.statsHint": "Tokens und Kosten dieser Session (Klick für Details)",
"chat.renameHint": "Doppelklick zum Umbenennen",
"chat.rewind": "Ab hier bearbeiten und neu senden",
"chat.rewindConfirm": "Diese Nachricht und alles danach entfernen? Dateiänderungen werden nicht rückgängig gemacht.",
"chat.delete": "Session löschen",
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
@@ -231,7 +298,12 @@ export const de: Messages = {
"settings.interface": "Oberfläche (dieses Gerät)",
"settings.language": "Sprache",
"settings.theme": "Design",
"settings.themeSystem": "System",
"settings.themeMode": "Modus",
"settings.themeAuto": "Auto",
"settings.themeLight": "Hell",
"settings.themeDark": "Dunkel",
"settings.themeSource": "Farben: {source} ({license})",
"settings.themeToggle": "Hell/Dunkel/Auto umschalten",
"settings.notifications": "Benachrichtigungen",
"settings.notificationsEnable": "Aktivieren",
"settings.notificationsOn": "Aktiv",
@@ -353,6 +425,7 @@ export const de: Messages = {
"stats.other": "Sonstige",
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
"common.ok": "OK",
"common.cancel": "Abbrechen",
"nav.logout": "Abmelden",
"login.username": "Benutzername",
+75 -2
View File
@@ -124,6 +124,74 @@ export const en = {
"git.noDiff": "No differences.",
"composer.attachedFiles": "Attached files (in the project, read them with your tools):",
"composer.uploading": "Uploading {n}…",
"usage.title": "Models and usage in this session",
"usage.period": "Period",
"usage.current": "current",
"usage.model": "Model",
"usage.prices": "Prices per 1M tokens",
"usage.usage": "Usage",
"usage.speed": "Speed",
"usage.cacheWrite": "Cache write",
"usage.contextNow": "Context now",
"usage.totalTime": "Total time",
"usage.inSession": "In this session",
"usage.providerMonth": "Provider this month",
"usage.byAgent": "By agent and model",
"preview.page": "Page {n} / {total}",
"preview.openBrowser": "Open in browser",
"preview.moreRows": "{n} more rows not shown",
"preview.moduleHint": "Tracker module – rendered to audio on the server (first playback may take a moment).",
"activity.phrases": "Pondering|Tinkering|Noodling|Brewing|Conjuring|Mulling|Crafting|Percolating|Ruminating|Whittling|Forging|Musing|Scheming|Untangling|Cogitating|Simmering|Wrangling|Hatching|Spelunking|Finagling|Concocting|Calibrating|Divining|Marinating",
"activity.prefill": "prefill",
"activity.generated": "generated",
"activity.prefilling": "reading prompt…",
"mcp.users": "Allowed users",
"mcp.usersPlaceholder": "admins only",
"mcp.usersHint": "Usernames separated by commas, or * for everybody. Admins always have access.",
"settings.budgetAction": "When a budget is used up",
"settings.budgetWarn": "Warn only",
"settings.budgetBlock": "Block new requests",
"settings.sandbox": "Shell sandbox",
"settings.sandboxAuto": "Users (not admins)",
"settings.sandboxAlways": "Everybody",
"settings.sandboxOff": "Off",
"settings.sandboxHint": "Shell commands and git run in a bubblewrap sandbox with only the user's folders writable.",
"users.quota": "Monthly quota",
"users.quotaHint": "Monthly cost limit (empty = unlimited)",
"users.quotaPrompt": "Monthly cost limit for {name} (empty = unlimited):",
"settings.tab.audit": "Audit",
"audit.title": "Audit log",
"audit.all": "All actions",
"audit.time": "Time",
"audit.action": "Action",
"audit.detail": "Details",
"audit.backup": "Backup",
"audit.backupHint": "ZIP with the configuration and a consistent copy of the database (sessions, users, statistics). Secrets referenced as ${VAR} are not included.",
"audit.download": "Download backup",
"craft.mode": "Craft mode",
"craft.modeHint": "Clean room: only what a user sees. Deep: also page sources and programs (Ghidra) – for re-creating, not copying.",
"craft.cleanRoom": "Clean room",
"craft.deep": "Deep",
"settings.tab.tools": "Tools",
"tools.installed": "installed",
"tools.install": "Install Ghidra",
"tools.reinstall": "Check / repair installation",
"tools.installing": "Installing (download ~800 MB, on ARM the decompiler is compiled – this takes several minutes)…",
"tools.ghidraHint": "Ghidra lets agents in craft Deep mode decompile programs (Windows .exe/.dll, Linux, DOS, …) to study how they work. Installed into the data volume together with its own JDK.",
"rewind.intro": "The conversation is reset to before this message; you can edit and resend it.",
"rewind.noFiles": "The agent did not change files since then.",
"rewind.restore": "Also restore the {n} file(s) the agent changed",
"rewind.willDelete": "will be deleted (new)",
"rewind.bashNote": "Changes made by shell commands (e.g. installed packages) are not restored.",
"rewind.confirm": "Rewind",
"files.discard": "Discard",
"files.overwrite": "Overwrite",
"shells.title": "Background shells",
"shells.chip": "{n}/{total} shells",
"shells.running": "running",
"shells.exited": "exited ({code})",
"shells.stop": "Stop",
"shells.noOutput": "(no output yet)",
"login.prompt": "Sign in to continue.",
"login.invalid": "Invalid token.",
"login.submit": "Sign in",
@@ -172,7 +240,6 @@ export const en = {
"chat.statsHint": "Tokens and cost of this session (click for details)",
"chat.renameHint": "Double-click to rename",
"chat.rewind": "Edit and resend from here",
"chat.rewindConfirm": "Remove this message and everything after it? File changes are not undone.",
"chat.delete": "Delete session",
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
@@ -230,7 +297,12 @@ export const en = {
"settings.interface": "Interface (this device)",
"settings.language": "Language",
"settings.theme": "Theme",
"settings.themeSystem": "System",
"settings.themeMode": "Mode",
"settings.themeAuto": "Auto",
"settings.themeLight": "Light",
"settings.themeDark": "Dark",
"settings.themeSource": "Colours: {source} ({license})",
"settings.themeToggle": "Switch light/dark/auto",
"settings.notifications": "Notifications",
"settings.notificationsEnable": "Enable",
"settings.notificationsOn": "Enabled",
@@ -352,6 +424,7 @@ export const en = {
"stats.other": "Other",
"stats.noData": "No requests in this period.",
"common.ok": "OK",
"common.cancel": "Cancel",
"nav.logout": "Sign out",
"login.username": "Username",
+14 -1
View File
@@ -39,6 +39,8 @@ export interface State {
questions: QuestionRequest[];
errors: Record<string, SessionError[]>;
mcp: Record<string, McpServerStatus>;
/** The model request currently running per session (activity line). */
steps: Record<string, { messageId: string; startedAt: number; promptTokens: number; estimated: boolean } | undefined>;
/** Messages waiting for a busy session. */
queue: Record<string, QueueItem[]>;
/** Why a session waits: "model:<spec>" or "subagent:<name>". */
@@ -60,6 +62,7 @@ let state: State = {
errors: {},
mcp: {},
queue: {},
steps: {},
waiting: {},
};
@@ -114,6 +117,7 @@ export function applyEvent(ev: ServerEvent) {
setState((s) => ({
busy: { ...s.busy, [ev.session_id]: ev.busy },
drafts: ev.busy ? s.drafts : { ...s.drafts, [ev.session_id]: undefined },
steps: ev.busy ? s.steps : { ...s.steps, [ev.session_id]: undefined },
}));
break;
case "message_added":
@@ -170,6 +174,11 @@ export function applyEvent(ev: ServerEvent) {
},
}));
break;
case "step_started":
setState((s) => ({
steps: { ...s.steps, [ev.session_id]: { messageId: ev.message_id, startedAt: ev.started_at, promptTokens: ev.prompt_tokens, estimated: ev.estimated } },
}));
break;
case "queue_updated":
setState((s) => ({ queue: { ...s.queue, [ev.session_id]: ev.items } }));
break;
@@ -207,7 +216,7 @@ export async function loadSessions() {
}
export async function loadSession(id: string) {
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[] }>(`/sessions/${id}`);
const data = await get<{ session: Session; messages: Message[]; busy: boolean; can_write: boolean; queue: QueueItem[]; step?: any }>(`/sessions/${id}`);
setState((s) => ({
...upsertSession(s, data.session),
sessionById: { ...s.sessionById, [id]: data.session },
@@ -215,6 +224,10 @@ export async function loadSession(id: string) {
busy: { ...s.busy, [id]: data.busy },
readOnly: { ...s.readOnly, [id]: !data.can_write },
queue: { ...s.queue, [id]: data.queue ?? [] },
steps: {
...s.steps,
[id]: data.step ? { messageId: data.step.message_id, startedAt: data.step.started_at, promptTokens: data.step.prompt_tokens, estimated: data.step.estimated } : s.steps[id],
},
}));
}
+264
View File
@@ -1826,3 +1826,267 @@ button.entry {
display: inline-block;
}
}
/* --- images in chat ------------------------------------------------------------ */
.markdown img.inline-image,
.svg-block img,
.image-result img {
max-width: min(100%, 640px);
max-height: 480px;
border-radius: 6px;
display: block;
margin: 6px 0;
background: repeating-conic-gradient(var(--bg1) 0% 25%, var(--bg0) 0% 50%) 50% / 16px 16px;
}
.svg-block {
margin: 8px 0;
}
.image-refs {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin: 6px 0;
}
.image-refs img {
height: 140px;
max-width: 260px;
object-fit: contain;
border-radius: 6px;
border: 1px solid var(--bg2);
background: repeating-conic-gradient(var(--bg1) 0% 25%, var(--bg0) 0% 50%) 50% / 16px 16px;
}
.image-result {
padding: 0 10px 8px;
}
.tool-head {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
font-size: 13px;
}
/* --- usage overlay ------------------------------------------------------------- */
.modal.wide {
width: min(1100px, 100%);
max-height: 90vh;
}
.kv-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 6px 20px;
}
.kv-title {
font-size: 11px;
text-transform: uppercase;
color: var(--muted);
margin: 4px 0 2px;
}
.kv {
display: flex;
justify-content: space-between;
gap: 10px;
font-size: 13px;
padding: 2px 0;
border-bottom: 1px dashed var(--bg2);
}
.kv span {
color: var(--fg3);
}
.kv b {
font-weight: 500;
color: var(--fg0);
text-align: right;
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
}
/* --- file previews ------------------------------------------------------------- */
.pdf-preview {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pdf-nav {
padding: 6px 10px;
border-bottom: 1px solid var(--bg2);
font-size: 13px;
}
.pdf-preview .image-preview img {
background: #fff;
box-shadow: var(--shadow);
}
.doc-text {
margin: 0;
padding: 16px;
white-space: pre-wrap;
font-size: 13px;
}
.sheet-tabs {
margin: 6px 10px;
flex-wrap: wrap;
align-self: flex-start;
}
table.grid.sheet td,
table.grid.sheet th {
white-space: nowrap;
border-right: 1px solid var(--bg2);
}
.audio-preview {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
}
.audio-preview audio {
width: min(520px, 100%);
}
.model-viewer {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.model-canvas {
flex: 1;
min-height: 300px;
touch-action: none;
}
.model-info {
font-size: 12px;
color: var(--muted);
padding: 4px 10px;
border-top: 1px solid var(--bg2);
}
.star {
color: var(--accent2);
font-size: 15px;
width: 1em;
display: inline-block;
text-align: center;
}
.activity-text {
color: var(--accent2);
}
.activity-meta {
color: var(--muted);
font-variant-numeric: tabular-nums;
}
tr.audit-warn td {
color: var(--accent2);
}
a.button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--on-accent);
font-weight: 600;
}
.mode-tabs button.active.deep {
background: var(--accent2);
}
.rewind-files {
max-height: 200px;
overflow-y: auto;
font-size: 12.5px;
padding-left: 18px;
}
/* --- in-app dialogs and toasts --------------------------------------------------- */
.modal.dialog {
width: min(420px, 100%);
z-index: 40;
}
.dialog-message {
white-space: pre-wrap;
font-size: 14px;
color: var(--fg1);
margin: 0 0 12px;
}
.dialog-actions {
justify-content: flex-end;
margin-top: 14px;
}
button.danger-solid {
background: var(--red);
border-color: var(--red);
color: var(--bg0);
font-weight: 600;
}
.toasts {
position: fixed;
right: 16px;
bottom: 16px;
display: grid;
gap: 8px;
z-index: 50;
max-width: min(420px, calc(100vw - 32px));
padding-bottom: env(safe-area-inset-bottom);
}
.toast {
background: var(--bg1);
border: 1px solid var(--bg3);
border-left: 4px solid var(--blue);
border-radius: var(--radius);
padding: 10px 14px;
box-shadow: var(--shadow);
font-size: 13px;
cursor: pointer;
animation: toast-in 0.18s ease-out;
word-break: break-word;
}
.toast.error {
border-left-color: var(--red);
}
.toast.success {
border-left-color: var(--green);
}
@keyframes toast-in {
from {
transform: translateY(8px);
opacity: 0;
}
}
.shell-output {
background: var(--bg0);
border: 1px solid var(--bg2);
border-radius: 6px;
padding: 8px 10px;
max-height: 55vh;
overflow: auto;
white-space: pre-wrap;
font-size: 12px;
margin: 0;
}
.segmented {
display: inline-flex;
gap: 2px;
background: var(--bg1);
border-radius: 8px;
padding: 2px;
width: fit-content;
}
.segmented button.active {
background: var(--accent);
color: var(--on-accent);
}
.sidebar-user .theme-toggle {
margin-left: auto;
font-size: 15px;
min-width: 32px;
}
.form .stack {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
+57 -18
View File
@@ -1,38 +1,77 @@
import "./themes";
const KEY = "codecraft.theme";
const MODE_KEY = "codecraft.themeMode";
/** Available themes; "system" follows the OS light/dark preference. */
/**
* Base themes. Each has a dark and a light variant (CSS files
* `themes/<id>-dark.css` / `<id>-light.css`); the mode picks the variant.
*/
export const THEMES = [
{ id: "system", name: "System", dark: true },
{ id: "gruvbox-dark", name: "Gruvbox Dark", dark: true },
{ id: "gruvbox-light", name: "Gruvbox Light", dark: false },
{ id: "gruvbox", name: "Gruvbox", source: "morhetz/gruvbox", license: "MIT" },
{ id: "ash", name: "Ash", source: "zink/trilium-themes", license: "MIT" },
{ id: "harbor", name: "Harbor", source: "HANCORE-linux/omarchy-harbor-theme", license: "MIT" },
{ id: "coffee", name: "Coffee", source: "megabyte0x/omarchy-coffee-theme", license: "MIT" },
{ id: "solarized", name: "Solarized", source: "Ethan Schoonover", license: "MIT" },
{ id: "tokyonight", name: "Tokyo Night", source: "folke/tokyonight.nvim", license: "Apache-2.0" },
{ id: "nord", name: "Nord", source: "nordtheme/nord", license: "MIT" },
{ id: "vscode", name: "VS Code", source: "microsoft/vscode", license: "MIT" },
{ id: "github", name: "GitHub", source: "primer/github-vscode-theme", license: "MIT" },
{ id: "catppuccin", name: "Catppuccin", source: "catppuccin/catppuccin", license: "MIT" },
{ id: "one", name: "One Dark / Light", source: "atom/atom", license: "MIT" },
{ id: "dracula", name: "Dracula / Alucard", source: "dracula/dracula-theme", license: "MIT" },
{ id: "rosepine", name: "Rosé Pine", source: "rose-pine/rose-pine-theme", license: "MIT" },
{ id: "everforest", name: "Everforest", source: "sainnhe/everforest", license: "MIT" },
{ id: "kanagawa", name: "Kanagawa", source: "rebelot/kanagawa.nvim", license: "MIT" },
] as const;
export type Theme = (typeof THEMES)[number]["id"];
/** Themes used for "system" in dark and light OS mode. */
const SYSTEM_DARK = "gruvbox-dark";
const SYSTEM_LIGHT = "gruvbox-light";
export type ThemeMode = "auto" | "light" | "dark";
export const THEME_MODES: ThemeMode[] = ["auto", "light", "dark"];
export function getTheme(): Theme {
const saved = localStorage.getItem(KEY);
// Migrate the old values "dark"/"light".
if (saved === "dark") return "gruvbox-dark";
if (saved === "light") return "gruvbox-light";
return THEMES.some((t) => t.id === saved) ? (saved as Theme) : "system";
const saved = localStorage.getItem(KEY) ?? "";
// Migrate old values like "gruvbox-dark", "dark", "system".
const base = saved.replace(/-(dark|light)$/, "");
return THEMES.some((t) => t.id === base) ? (base as Theme) : "gruvbox";
}
export function getThemeMode(): ThemeMode {
const saved = localStorage.getItem(MODE_KEY);
if (saved === "light" || saved === "dark" || saved === "auto") return saved;
// Migrate the old combined setting.
const old = localStorage.getItem(KEY) ?? "";
if (old.endsWith("dark")) return "dark";
if (old.endsWith("light")) return "light";
return "auto";
}
const media = matchMedia("(prefers-color-scheme: dark)");
const listeners = new Set<() => void>();
export function applyTheme(theme: Theme = getTheme()) {
export function onThemeChange(fn: () => void) {
listeners.add(fn);
return () => void listeners.delete(fn);
}
export function applyTheme(theme: Theme = getTheme(), mode: ThemeMode = getThemeMode()) {
localStorage.setItem(KEY, theme);
const resolved = theme === "system" ? (media.matches ? SYSTEM_DARK : SYSTEM_LIGHT) : theme;
document.documentElement.dataset.theme = resolved;
const color = getComputedStyle(document.documentElement).getPropertyValue("--theme-color").trim();
localStorage.setItem(MODE_KEY, mode);
const dark = mode === "auto" ? media.matches : mode === "dark";
const root = document.documentElement;
root.dataset.theme = `${theme}-${dark ? "dark" : "light"}`;
root.dataset.mode = dark ? "dark" : "light";
const color = getComputedStyle(root).getPropertyValue("--theme-color").trim();
document.querySelector('meta[name="theme-color"]')?.setAttribute("content", color || "#282828");
listeners.forEach((fn) => fn());
}
/** Cycle auto → light → dark (quick switcher). */
export function cycleThemeMode() {
const modes = THEME_MODES;
applyTheme(getTheme(), modes[(modes.indexOf(getThemeMode()) + 1) % modes.length]);
}
media.addEventListener("change", () => {
if (getTheme() === "system") applyTheme("system");
if (getThemeMode() === "auto") applyTheme();
});
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Richard Zink
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+40
View File
@@ -0,0 +1,40 @@
/*
* Ash Dark
* Original Ash palette (Gruvbox-derived) from zink/trilium-themes (MIT).
*/
:root[data-theme="ash-dark"] {
color-scheme: dark;
--theme-color: #1c1b1f;
--bg0: #1c1b1f;
--bg1: #232226;
--bg2: #29282c;
--bg3: #3a383d;
--fg0: #f3ede1;
--fg1: #e6dfd1;
--fg2: #cfc7b8;
--fg3: #b0a89b;
--muted: #999187;
--accent: #e0914f;
--accent2: #d6b46a;
--on-accent: #1c1b1f;
--red: #c15c46;
--green: #8f9a5c;
--aqua: #8aa89a;
--blue: #7c9aa1;
--purple: #b47e99;
--diff-add: rgba(143, 154, 92, 0.16);
--diff-del: rgba(193, 92, 70, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #c15c46;
--syntax-string: #8f9a5c;
--syntax-number: #a585ac;
--syntax-comment: #6d6a63;
--syntax-function: #7c9aa1;
--syntax-type: #d6b46a;
--syntax-variable: #e6dfd1;
--syntax-meta: #e0914f;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Ash Light
* Original Ash palette (Gruvbox-derived) from zink/trilium-themes (MIT).
*/
:root[data-theme="ash-light"] {
color-scheme: light;
--theme-color: #e9e3d5;
--bg0: #e9e3d5;
--bg1: #dcd5c3;
--bg2: #d2cab7;
--bg3: #c9c1ad;
--fg0: #1e1c18;
--fg1: #2a2822;
--fg2: #433f37;
--fg3: #5a554b;
--muted: #6f685c;
--accent: #a85f1d;
--accent2: #8a6a24;
--on-accent: #ffffff;
--red: #96392a;
--green: #5c6631;
--aqua: #3d6b72;
--blue: #3d6b72;
--purple: #7c4a66;
--diff-add: rgba(92, 102, 49, 0.16);
--diff-del: rgba(150, 57, 42, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #96392a;
--syntax-string: #5c6631;
--syntax-number: #6a4c72;
--syntax-comment: #8a8274;
--syntax-function: #3d6b72;
--syntax-type: #8a6a24;
--syntax-variable: #2a2822;
--syntax-meta: #a85f1d;
}
+37
View File
@@ -1,3 +1,40 @@
/* Chart palettes per light/dark mode (validated for colour-vision deficiencies). */
:root,
:root[data-mode="dark"] {
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--seq-0: var(--bg1);
--seq-1: #104281;
--seq-2: #184f95;
--seq-3: #256abf;
--seq-4: #3987e5;
--seq-5: #6da7ec;
--seq-6: #9ec5f4;
}
:root[data-mode="light"] {
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #6250d6;
--series-8: #e34948;
--seq-0: var(--bg1);
--seq-1: #cde2fb;
--seq-2: #9ec5f4;
--seq-3: #6da7ec;
--seq-4: #3987e5;
--seq-5: #256abf;
--seq-6: #184f95;
}
/* Theme-independent tokens. */
:root {
--radius: 8px;
+40
View File
@@ -0,0 +1,40 @@
/*
* Catppuccin Mocha
* Palette: Catppuccin Mocha (catppuccin/catppuccin, MIT).
*/
:root[data-theme="catppuccin-dark"] {
color-scheme: dark;
--theme-color: #1e1e2e;
--bg0: #1e1e2e;
--bg1: #181825;
--bg2: #313244;
--bg3: #45475a;
--fg0: #cdd6f4;
--fg1: #cdd6f4;
--fg2: #bac2de;
--fg3: #a6adc8;
--muted: #9399b2;
--accent: #cba6f7;
--accent2: #fab387;
--on-accent: #1e1e2e;
--red: #f38ba8;
--green: #a6e3a1;
--aqua: #94e2d5;
--blue: #89b4fa;
--purple: #f5c2e7;
--diff-add: rgba(166, 227, 161, 0.16);
--diff-del: rgba(243, 139, 168, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #cba6f7;
--syntax-string: #a6e3a1;
--syntax-number: #fab387;
--syntax-comment: #7f849c;
--syntax-function: #89b4fa;
--syntax-type: #f9e2af;
--syntax-variable: #cdd6f4;
--syntax-meta: #f5c2e7;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Catppuccin Latte
* Palette: Catppuccin Latte (catppuccin/catppuccin, MIT).
*/
:root[data-theme="catppuccin-light"] {
color-scheme: light;
--theme-color: #eff1f5;
--bg0: #eff1f5;
--bg1: #e6e9ef;
--bg2: #ccd0da;
--bg3: #bcc0cc;
--fg0: #4c4f69;
--fg1: #4c4f69;
--fg2: #5c5f77;
--fg3: #6c6f85;
--muted: #6c6f85;
--accent: #8839ef;
--accent2: #fe640b;
--on-accent: #ffffff;
--red: #d20f39;
--green: #40a02b;
--aqua: #179299;
--blue: #1e66f5;
--purple: #ea76cb;
--diff-add: rgba(64, 160, 43, 0.16);
--diff-del: rgba(210, 15, 57, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #8839ef;
--syntax-string: #40a02b;
--syntax-number: #fe640b;
--syntax-comment: #8c8fa1;
--syntax-function: #1e66f5;
--syntax-type: #df8e1d;
--syntax-variable: #4c4f69;
--syntax-meta: #ea76cb;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Coffee Dark
* Palette: megabyte0x/omarchy-coffee-theme (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="coffee-dark"] {
color-scheme: dark;
--theme-color: #1d150f;
--bg0: #1d150f;
--bg1: #2d2118;
--bg2: #3a2a20;
--bg3: #4a372a;
--fg0: #f8eee0;
--fg1: #f0e2d0;
--fg2: #d8c6b0;
--fg3: #b09a84;
--muted: #8f7a67;
--accent: #e0a15c;
--accent2: #ffda92;
--on-accent: #1d150f;
--red: #d78a6f;
--green: #b5b27a;
--aqua: #9fb8a8;
--blue: #a3b4c7;
--purple: #cf90a2;
--diff-add: rgba(181, 178, 122, 0.16);
--diff-del: rgba(215, 138, 111, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #d78a6f;
--syntax-string: #ffda92;
--syntax-number: #cf90a2;
--syntax-comment: #8f7a67;
--syntax-function: #e0a15c;
--syntax-type: #ec8f89;
--syntax-variable: #f0e2d0;
--syntax-meta: #ec8f89;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Coffee Latte
* Palette: megabyte0x/omarchy-coffee-latte-theme (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="coffee-light"] {
color-scheme: light;
--theme-color: #e8e3c3;
--bg0: #e8e3c3;
--bg1: #ddd8b8;
--bg2: #d1ccad;
--bg3: #c5c1a6;
--fg0: #1f1013;
--fg1: #2d1a1d;
--fg2: #45302f;
--fg3: #5d4744;
--muted: #6e5a55;
--accent: #8e2d53;
--accent2: #b25d76;
--on-accent: #ffffff;
--red: #a4405e;
--green: #4f6b00;
--aqua: #3f6b5e;
--blue: #8e2d53;
--purple: #7a003b;
--diff-add: rgba(79, 107, 0, 0.16);
--diff-del: rgba(164, 64, 94, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #a4405e;
--syntax-string: #4f6b00;
--syntax-number: #7a003b;
--syntax-comment: #8a7d70;
--syntax-function: #8e2d53;
--syntax-type: #b25d76;
--syntax-variable: #2d1a1d;
--syntax-meta: #b25d76;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Dracula
* Palette: Dracula (dracula/dracula-theme, MIT).
*/
:root[data-theme="dracula-dark"] {
color-scheme: dark;
--theme-color: #282a36;
--bg0: #282a36;
--bg1: #21222c;
--bg2: #343746;
--bg3: #44475a;
--fg0: #f8f8f2;
--fg1: #f8f8f2;
--fg2: #e2e2dc;
--fg3: #c0c0ba;
--muted: #9aa3c9;
--accent: #bd93f9;
--accent2: #ffb86c;
--on-accent: #282a36;
--red: #ff5555;
--green: #50fa7b;
--aqua: #8be9fd;
--blue: #8be9fd;
--purple: #ff79c6;
--diff-add: rgba(80, 250, 123, 0.16);
--diff-del: rgba(255, 85, 85, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #ff79c6;
--syntax-string: #f1fa8c;
--syntax-number: #bd93f9;
--syntax-comment: #6272a4;
--syntax-function: #50fa7b;
--syntax-type: #8be9fd;
--syntax-variable: #f8f8f2;
--syntax-meta: #ffb86c;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Alucard
* Palette: Alucard, the light Dracula variant (dracula/dracula-theme, MIT).
*/
:root[data-theme="dracula-light"] {
color-scheme: light;
--theme-color: #fffbeb;
--bg0: #fffbeb;
--bg1: #f5f1dc;
--bg2: #efeddc;
--bg3: #cfcfde;
--fg0: #1f1f1f;
--fg1: #1f1f1f;
--fg2: #3a3a3a;
--fg3: #6c664b;
--muted: #6c664b;
--accent: #644ac9;
--accent2: #a34d14;
--on-accent: #ffffff;
--red: #cb3a2a;
--green: #14710a;
--aqua: #036a96;
--blue: #036a96;
--purple: #a3144d;
--diff-add: rgba(20, 113, 10, 0.16);
--diff-del: rgba(203, 58, 42, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #a3144d;
--syntax-string: #846e15;
--syntax-number: #644ac9;
--syntax-comment: #6c664b;
--syntax-function: #14710a;
--syntax-type: #036a96;
--syntax-variable: #1f1f1f;
--syntax-meta: #a34d14;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Everforest Dark
* Palette: Everforest by sainnhe (sainnhe/everforest, MIT).
*/
:root[data-theme="everforest-dark"] {
color-scheme: dark;
--theme-color: #2d353b;
--bg0: #2d353b;
--bg1: #343f44;
--bg2: #3d484d;
--bg3: #475258;
--fg0: #e5dcc4;
--fg1: #d3c6aa;
--fg2: #c0b597;
--fg3: #9da9a0;
--muted: #859289;
--accent: #a7c080;
--accent2: #e69875;
--on-accent: #2d353b;
--red: #e67e80;
--green: #a7c080;
--aqua: #83c092;
--blue: #7fbbb3;
--purple: #d699b6;
--diff-add: rgba(167, 192, 128, 0.16);
--diff-del: rgba(230, 126, 128, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #e67e80;
--syntax-string: #dbbc7f;
--syntax-number: #d699b6;
--syntax-comment: #859289;
--syntax-function: #a7c080;
--syntax-type: #7fbbb3;
--syntax-variable: #d3c6aa;
--syntax-meta: #e69875;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Everforest Light
* Palette: Everforest by sainnhe (sainnhe/everforest, MIT).
*/
:root[data-theme="everforest-light"] {
color-scheme: light;
--theme-color: #fdf6e3;
--bg0: #fdf6e3;
--bg1: #f4f0d9;
--bg2: #efebd4;
--bg3: #e0dcc7;
--fg0: #4a565c;
--fg1: #5c6a72;
--fg2: #66757c;
--fg3: #748086;
--muted: #7a8478;
--accent: #8da101;
--accent2: #f57d26;
--on-accent: #2d353b;
--red: #f85552;
--green: #8da101;
--aqua: #35a77c;
--blue: #3a94c5;
--purple: #df69ba;
--diff-add: rgba(141, 161, 1, 0.16);
--diff-del: rgba(248, 85, 82, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #f85552;
--syntax-string: #dfa000;
--syntax-number: #df69ba;
--syntax-comment: #939f91;
--syntax-function: #8da101;
--syntax-type: #3a94c5;
--syntax-variable: #5c6a72;
--syntax-meta: #f57d26;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* GitHub Dark
* Palette: GitHub Primer / github-vscode-theme (primer, MIT).
*/
:root[data-theme="github-dark"] {
color-scheme: dark;
--theme-color: #0d1117;
--bg0: #0d1117;
--bg1: #161b22;
--bg2: #21262d;
--bg3: #30363d;
--fg0: #f0f6fc;
--fg1: #e6edf3;
--fg2: #c9d1d9;
--fg3: #9198a1;
--muted: #8b949e;
--accent: #4493f8;
--accent2: #d29922;
--on-accent: #0d1117;
--red: #f85149;
--green: #3fb950;
--aqua: #39c5cf;
--blue: #58a6ff;
--purple: #bc8cff;
--diff-add: rgba(63, 185, 80, 0.16);
--diff-del: rgba(248, 81, 73, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #ff7b72;
--syntax-string: #a5d6ff;
--syntax-number: #79c0ff;
--syntax-comment: #8b949e;
--syntax-function: #d2a8ff;
--syntax-type: #ffa657;
--syntax-variable: #e6edf3;
--syntax-meta: #7ee787;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* GitHub Light
* Palette: GitHub Primer / github-vscode-theme (primer, MIT).
*/
:root[data-theme="github-light"] {
color-scheme: light;
--theme-color: #ffffff;
--bg0: #ffffff;
--bg1: #f6f8fa;
--bg2: #eaeef2;
--bg3: #d0d7de;
--fg0: #1f2328;
--fg1: #1f2328;
--fg2: #31363c;
--fg3: #59636e;
--muted: #59636e;
--accent: #0969da;
--accent2: #9a6700;
--on-accent: #ffffff;
--red: #d1242f;
--green: #1a7f37;
--aqua: #1b7c83;
--blue: #0969da;
--purple: #8250df;
--diff-add: rgba(26, 127, 55, 0.16);
--diff-del: rgba(209, 36, 47, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #cf222e;
--syntax-string: #0a3069;
--syntax-number: #0550ae;
--syntax-comment: #59636e;
--syntax-function: #8250df;
--syntax-type: #953800;
--syntax-variable: #1f2328;
--syntax-meta: #116329;
}
+4 -17
View File
@@ -1,4 +1,7 @@
/* Gruvbox Dark – also the default before a theme is applied. */
/*
* Gruvbox Dark – also the default before a theme is applied.
* Palette: morhetz/gruvbox (MIT), via zink/trilium-themes (MIT).
*/
:root,
:root[data-theme="gruvbox-dark"] {
color-scheme: dark;
@@ -36,20 +39,4 @@
--syntax-variable: #83a598;
--syntax-meta: #fe8019;
/* Validated categorical palette (dark steps on #282828). */
--series-1: #3987e5;
--series-2: #d95926;
--series-3: #199e70;
--series-4: #c98500;
--series-5: #d55181;
--series-6: #008300;
--series-7: #9085e9;
--series-8: #e66767;
--seq-0: #32302f;
--seq-1: #104281;
--seq-2: #184f95;
--seq-3: #256abf;
--seq-4: #3987e5;
--seq-5: #6da7ec;
--seq-6: #9ec5f4;
}
+5 -18
View File
@@ -1,4 +1,7 @@
/* Gruvbox Light */
/*
* Gruvbox Light
* Palette: morhetz/gruvbox (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="gruvbox-light"] {
color-scheme: light;
--theme-color: #fbf1c7;
@@ -15,7 +18,7 @@
--accent: #b57614;
--accent2: #af3a03;
--on-accent: #fbf1c7;
--on-accent: #282828;
--red: #9d0006;
--green: #79740e;
--aqua: #427b58;
@@ -35,20 +38,4 @@
--syntax-variable: #076678;
--syntax-meta: #af3a03;
/* Validated categorical palette (light steps on #fbf1c7). */
--series-1: #2a78d6;
--series-2: #eb6834;
--series-3: #1baf7a;
--series-4: #eda100;
--series-5: #e87ba4;
--series-6: #008300;
--series-7: #6250d6;
--series-8: #e34948;
--seq-0: #f2e5bc;
--seq-1: #cde2fb;
--seq-2: #9ec5f4;
--seq-3: #6da7ec;
--seq-4: #3987e5;
--seq-5: #256abf;
--seq-6: #184f95;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Harbor Dark
* Palette: HANCORE-linux/omarchy-harbordark-theme (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="harbor-dark"] {
color-scheme: dark;
--theme-color: #1b1b1b;
--bg0: #1b1b1b;
--bg1: #232323;
--bg2: #2e2e2e;
--bg3: #3f3f3f;
--fg0: #f7f4ea;
--fg1: #efebdc;
--fg2: #d6d1c0;
--fg3: #c0b79f;
--muted: #a99b7a;
--accent: #e75a50;
--accent2: #e1ce98;
--on-accent: #1b1b1b;
--red: #f44336;
--green: #a99b7a;
--aqua: #8a9ba6;
--blue: #77838a;
--purple: #e58980;
--diff-add: rgba(169, 155, 122, 0.16);
--diff-del: rgba(244, 67, 54, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #e75a50;
--syntax-string: #e1ce98;
--syntax-number: #e58980;
--syntax-comment: #77838a;
--syntax-function: #a99b7a;
--syntax-type: #e1ce98;
--syntax-variable: #efebdc;
--syntax-meta: #e58980;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Harbor Light
* Palette: HANCORE-linux/omarchy-harbor-theme (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="harbor-light"] {
color-scheme: light;
--theme-color: #dfe4c4;
--bg0: #dfe4c4;
--bg1: #d0d6b4;
--bg2: #c3c9a6;
--bg3: #b3b994;
--fg0: #101c18;
--fg1: #1c2d28;
--fg2: #2f413b;
--fg3: #435650;
--muted: #556753;
--accent: #4c6c94;
--accent2: #dc8164;
--on-accent: #ffffff;
--red: #b14752;
--green: #556753;
--aqua: #3d727d;
--blue: #4c6c94;
--purple: #8a5b81;
--diff-add: rgba(85, 103, 83, 0.16);
--diff-del: rgba(177, 71, 82, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #b14752;
--syntax-string: #556753;
--syntax-number: #8a5b81;
--syntax-comment: #6f7d6c;
--syntax-function: #3d727d;
--syntax-type: #4c6c94;
--syntax-variable: #1c2d28;
--syntax-meta: #b06247;
}
+28
View File
@@ -2,3 +2,31 @@
import "./base.css";
import "./gruvbox-dark.css";
import "./gruvbox-light.css";
import "./ash-dark.css";
import "./ash-light.css";
import "./harbor-dark.css";
import "./harbor-light.css";
import "./coffee-dark.css";
import "./coffee-light.css";
import "./solarized-dark.css";
import "./solarized-light.css";
import "./tokyonight-dark.css";
import "./tokyonight-light.css";
import "./nord-dark.css";
import "./nord-light.css";
import "./vscode-dark.css";
import "./vscode-light.css";
import "./github-dark.css";
import "./github-light.css";
import "./catppuccin-dark.css";
import "./catppuccin-light.css";
import "./one-dark.css";
import "./one-light.css";
import "./dracula-dark.css";
import "./dracula-light.css";
import "./rosepine-dark.css";
import "./rosepine-light.css";
import "./everforest-dark.css";
import "./everforest-light.css";
import "./kanagawa-dark.css";
import "./kanagawa-light.css";
+40
View File
@@ -0,0 +1,40 @@
/*
* Kanagawa Wave
* Palette: Kanagawa by rebelot (rebelot/kanagawa.nvim, MIT).
*/
:root[data-theme="kanagawa-dark"] {
color-scheme: dark;
--theme-color: #1f1f28;
--bg0: #1f1f28;
--bg1: #16161d;
--bg2: #2a2a37;
--bg3: #363646;
--fg0: #dcd7ba;
--fg1: #dcd7ba;
--fg2: #c8c093;
--fg3: #a6a69c;
--muted: #8f8e85;
--accent: #7e9cd8;
--accent2: #ffa066;
--on-accent: #1f1f28;
--red: #e46876;
--green: #98bb6c;
--aqua: #7aa89f;
--blue: #7fb4ca;
--purple: #957fb8;
--diff-add: rgba(152, 187, 108, 0.16);
--diff-del: rgba(228, 104, 118, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #957fb8;
--syntax-string: #98bb6c;
--syntax-number: #d27e99;
--syntax-comment: #727169;
--syntax-function: #7e9cd8;
--syntax-type: #7aa89f;
--syntax-variable: #e6c384;
--syntax-meta: #ffa066;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Kanagawa Lotus
* Palette: Kanagawa Lotus by rebelot (rebelot/kanagawa.nvim, MIT).
*/
:root[data-theme="kanagawa-light"] {
color-scheme: light;
--theme-color: #f2ecbc;
--bg0: #f2ecbc;
--bg1: #e5ddb0;
--bg2: #dcd5ac;
--bg3: #d5cea3;
--fg0: #43436c;
--fg1: #545464;
--fg2: #5f5f72;
--fg3: #6d6d7c;
--muted: #716e61;
--accent: #4d699b;
--accent2: #cc6d00;
--on-accent: #ffffff;
--red: #c84053;
--green: #6f894e;
--aqua: #597b75;
--blue: #4d699b;
--purple: #624c83;
--diff-add: rgba(111, 137, 78, 0.16);
--diff-del: rgba(200, 64, 83, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #624c83;
--syntax-string: #6f894e;
--syntax-number: #b35b79;
--syntax-comment: #8a8980;
--syntax-function: #4d699b;
--syntax-type: #597b75;
--syntax-variable: #545464;
--syntax-meta: #cc6d00;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Nord
* Palette: Nord by Arctic Ice Studio / nordtheme (MIT).
*/
:root[data-theme="nord-dark"] {
color-scheme: dark;
--theme-color: #2e3440;
--bg0: #2e3440;
--bg1: #3b4252;
--bg2: #434c5e;
--bg3: #4c566a;
--fg0: #eceff4;
--fg1: #e5e9f0;
--fg2: #d8dee9;
--fg3: #b8c0cf;
--muted: #8a93a5;
--accent: #88c0d0;
--accent2: #d08770;
--on-accent: #2e3440;
--red: #bf616a;
--green: #a3be8c;
--aqua: #8fbcbb;
--blue: #81a1c1;
--purple: #b48ead;
--diff-add: rgba(163, 190, 140, 0.16);
--diff-del: rgba(191, 97, 106, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #81a1c1;
--syntax-string: #a3be8c;
--syntax-number: #b48ead;
--syntax-comment: #616e88;
--syntax-function: #88c0d0;
--syntax-type: #8fbcbb;
--syntax-variable: #d8dee9;
--syntax-meta: #d08770;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Nord Light
* Palette: Nord by Arctic Ice Studio / nordtheme (MIT), Snow Storm as background.
*/
:root[data-theme="nord-light"] {
color-scheme: light;
--theme-color: #eceff4;
--bg0: #eceff4;
--bg1: #e5e9f0;
--bg2: #d8dee9;
--bg3: #c2c9d6;
--fg0: #2e3440;
--fg1: #3b4252;
--fg2: #434c5e;
--fg3: #4c566a;
--muted: #5f6b80;
--accent: #5e81ac;
--accent2: #b55f45;
--on-accent: #ffffff;
--red: #a9434d;
--green: #5f7d48;
--aqua: #4d8a89;
--blue: #5e81ac;
--purple: #8f6a8a;
--diff-add: rgba(95, 125, 72, 0.16);
--diff-del: rgba(169, 67, 77, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #5e81ac;
--syntax-string: #5f7d48;
--syntax-number: #8f6a8a;
--syntax-comment: #7b88a1;
--syntax-function: #4c7a90;
--syntax-type: #4d8a89;
--syntax-variable: #3b4252;
--syntax-meta: #b55f45;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* One Dark
* Palette: Atom One Dark (atom/atom, MIT).
*/
:root[data-theme="one-dark"] {
color-scheme: dark;
--theme-color: #282c34;
--bg0: #282c34;
--bg1: #21252b;
--bg2: #2c313a;
--bg3: #3e4451;
--fg0: #d7dae0;
--fg1: #abb2bf;
--fg2: #9da5b4;
--fg3: #8b929e;
--muted: #7f848e;
--accent: #61afef;
--accent2: #d19a66;
--on-accent: #282c34;
--red: #e06c75;
--green: #98c379;
--aqua: #56b6c2;
--blue: #61afef;
--purple: #c678dd;
--diff-add: rgba(152, 195, 121, 0.16);
--diff-del: rgba(224, 108, 117, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #c678dd;
--syntax-string: #98c379;
--syntax-number: #d19a66;
--syntax-comment: #7f848e;
--syntax-function: #61afef;
--syntax-type: #e5c07b;
--syntax-variable: #e06c75;
--syntax-meta: #56b6c2;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* One Light
* Palette: Atom One Light (atom/atom, MIT).
*/
:root[data-theme="one-light"] {
color-scheme: light;
--theme-color: #fafafa;
--bg0: #fafafa;
--bg1: #f0f0f1;
--bg2: #e5e5e6;
--bg3: #d4d4d6;
--fg0: #232324;
--fg1: #383a42;
--fg2: #4f525e;
--fg3: #696c77;
--muted: #696c77;
--accent: #4078f2;
--accent2: #c18401;
--on-accent: #ffffff;
--red: #e45649;
--green: #50a14f;
--aqua: #0184bc;
--blue: #4078f2;
--purple: #a626a4;
--diff-add: rgba(80, 161, 79, 0.16);
--diff-del: rgba(228, 86, 73, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #a626a4;
--syntax-string: #50a14f;
--syntax-number: #986801;
--syntax-comment: #a0a1a7;
--syntax-function: #4078f2;
--syntax-type: #c18401;
--syntax-variable: #e45649;
--syntax-meta: #0184bc;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Rosé Pine
* Palette: Rosé Pine (rose-pine/rose-pine-theme, MIT).
*/
:root[data-theme="rosepine-dark"] {
color-scheme: dark;
--theme-color: #191724;
--bg0: #191724;
--bg1: #1f1d2e;
--bg2: #26233a;
--bg3: #403d52;
--fg0: #e0def4;
--fg1: #e0def4;
--fg2: #c9c6e0;
--fg3: #908caa;
--muted: #908caa;
--accent: #c4a7e7;
--accent2: #ebbcba;
--on-accent: #191724;
--red: #eb6f92;
--green: #9ccfd8;
--aqua: #9ccfd8;
--blue: #31748f;
--purple: #c4a7e7;
--diff-add: rgba(156, 207, 216, 0.16);
--diff-del: rgba(235, 111, 146, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #31748f;
--syntax-string: #f6c177;
--syntax-number: #f6c177;
--syntax-comment: #6e6a86;
--syntax-function: #ebbcba;
--syntax-type: #9ccfd8;
--syntax-variable: #e0def4;
--syntax-meta: #c4a7e7;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Rosé Pine Dawn
* Palette: Rosé Pine Dawn (rose-pine/rose-pine-theme, MIT).
*/
:root[data-theme="rosepine-light"] {
color-scheme: light;
--theme-color: #faf4ed;
--bg0: #faf4ed;
--bg1: #f2e9e1;
--bg2: #dfdad9;
--bg3: #cecacd;
--fg0: #464261;
--fg1: #575279;
--fg2: #6a6585;
--fg3: #797593;
--muted: #797593;
--accent: #286983;
--accent2: #d7827e;
--on-accent: #ffffff;
--red: #b4637a;
--green: #56949f;
--aqua: #56949f;
--blue: #286983;
--purple: #907aa9;
--diff-add: rgba(86, 148, 159, 0.16);
--diff-del: rgba(180, 99, 122, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #286983;
--syntax-string: #ea9d34;
--syntax-number: #ea9d34;
--syntax-comment: #9893a5;
--syntax-function: #d7827e;
--syntax-type: #56949f;
--syntax-variable: #575279;
--syntax-meta: #907aa9;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Solarized Dark
* Palette: Solarized by Ethan Schoonover (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="solarized-dark"] {
color-scheme: dark;
--theme-color: #002b36;
--bg0: #002b36;
--bg1: #073642;
--bg2: #0b4250;
--bg3: #155565;
--fg0: #eee8d5;
--fg1: #93a1a1;
--fg2: #839496;
--fg3: #839496;
--muted: #6c8288;
--accent: #b58900;
--accent2: #cb4b16;
--on-accent: #002b36;
--red: #dc322f;
--green: #859900;
--aqua: #2aa198;
--blue: #268bd2;
--purple: #6c71c4;
--diff-add: rgba(133, 153, 0, 0.16);
--diff-del: rgba(220, 50, 47, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #859900;
--syntax-string: #2aa198;
--syntax-number: #d33682;
--syntax-comment: #586e75;
--syntax-function: #268bd2;
--syntax-type: #b58900;
--syntax-variable: #93a1a1;
--syntax-meta: #cb4b16;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Solarized Light
* Palette: Solarized by Ethan Schoonover (MIT), via zink/trilium-themes (MIT).
*/
:root[data-theme="solarized-light"] {
color-scheme: light;
--theme-color: #fdf6e3;
--bg0: #fdf6e3;
--bg1: #eee8d5;
--bg2: #e4dcc4;
--bg3: #d6ceb5;
--fg0: #073642;
--fg1: #586e75;
--fg2: #657b83;
--fg3: #657b83;
--muted: #6f8186;
--accent: #b58900;
--accent2: #cb4b16;
--on-accent: #002b36;
--red: #dc322f;
--green: #859900;
--aqua: #2aa198;
--blue: #268bd2;
--purple: #6c71c4;
--diff-add: rgba(133, 153, 0, 0.16);
--diff-del: rgba(220, 50, 47, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #859900;
--syntax-string: #2aa198;
--syntax-number: #d33682;
--syntax-comment: #93a1a1;
--syntax-function: #268bd2;
--syntax-type: #b58900;
--syntax-variable: #586e75;
--syntax-meta: #cb4b16;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Tokyo Night
* Palette: folke/tokyonight.nvim (Apache-2.0), via zink/trilium-themes (MIT).
*/
:root[data-theme="tokyonight-dark"] {
color-scheme: dark;
--theme-color: #24283b;
--bg0: #24283b;
--bg1: #1f2335;
--bg2: #292e42;
--bg3: #3b4261;
--fg0: #c0caf5;
--fg1: #c0caf5;
--fg2: #a9b1d6;
--fg3: #9aa5ce;
--muted: #737aa2;
--accent: #7aa2f7;
--accent2: #ff9e64;
--on-accent: #24283b;
--red: #f7768e;
--green: #9ece6a;
--aqua: #73daca;
--blue: #7aa2f7;
--purple: #bb9af7;
--diff-add: rgba(158, 206, 106, 0.16);
--diff-del: rgba(247, 118, 142, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #bb9af7;
--syntax-string: #9ece6a;
--syntax-number: #ff9e64;
--syntax-comment: #565f89;
--syntax-function: #7aa2f7;
--syntax-type: #2ac3de;
--syntax-variable: #7dcfff;
--syntax-meta: #e0af68;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* Tokyo Night Day
* Palette: folke/tokyonight.nvim (Apache-2.0), via zink/trilium-themes (MIT).
*/
:root[data-theme="tokyonight-light"] {
color-scheme: light;
--theme-color: #e1e2e7;
--bg0: #e1e2e7;
--bg1: #d0d5e3;
--bg2: #c4c8da;
--bg3: #a8aecb;
--fg0: #273463;
--fg1: #3760bf;
--fg2: #4b5a99;
--fg3: #6172b0;
--muted: #6c74a0;
--accent: #2e7de9;
--accent2: #b15c00;
--on-accent: #ffffff;
--red: #f52a65;
--green: #587539;
--aqua: #118c74;
--blue: #2e7de9;
--purple: #9854f1;
--diff-add: rgba(88, 117, 57, 0.16);
--diff-del: rgba(245, 42, 101, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #9854f1;
--syntax-string: #587539;
--syntax-number: #b15c00;
--syntax-comment: #848cb5;
--syntax-function: #2e7de9;
--syntax-type: #007197;
--syntax-variable: #3760bf;
--syntax-meta: #8c6c3e;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* VS Code Dark Modern
* Palette: Visual Studio Code Dark Modern / Dark+ (microsoft/vscode, MIT).
*/
:root[data-theme="vscode-dark"] {
color-scheme: dark;
--theme-color: #1f1f1f;
--bg0: #1f1f1f;
--bg1: #181818;
--bg2: #2b2b2b;
--bg3: #3c3c3c;
--fg0: #ffffff;
--fg1: #cccccc;
--fg2: #bbbbbb;
--fg3: #9d9d9d;
--muted: #8b8b8b;
--accent: #0078d4;
--accent2: #ce9178;
--on-accent: #ffffff;
--red: #f14c4c;
--green: #89d185;
--aqua: #4ec9b0;
--blue: #3794ff;
--purple: #c586c0;
--diff-add: rgba(137, 209, 133, 0.16);
--diff-del: rgba(241, 76, 76, 0.16);
--shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
--syntax-keyword: #569cd6;
--syntax-string: #ce9178;
--syntax-number: #b5cea8;
--syntax-comment: #6a9955;
--syntax-function: #dcdcaa;
--syntax-type: #4ec9b0;
--syntax-variable: #9cdcfe;
--syntax-meta: #c586c0;
}
+40
View File
@@ -0,0 +1,40 @@
/*
* VS Code Light Modern
* Palette: Visual Studio Code Light Modern / Light+ (microsoft/vscode, MIT).
*/
:root[data-theme="vscode-light"] {
color-scheme: light;
--theme-color: #ffffff;
--bg0: #ffffff;
--bg1: #f8f8f8;
--bg2: #e5e5e5;
--bg3: #cecece;
--fg0: #000000;
--fg1: #3b3b3b;
--fg2: #444444;
--fg3: #616161;
--muted: #6e7681;
--accent: #005fb8;
--accent2: #a31515;
--on-accent: #ffffff;
--red: #cd3131;
--green: #388a34;
--aqua: #267f99;
--blue: #005fb8;
--purple: #af00db;
--diff-add: rgba(56, 138, 52, 0.16);
--diff-del: rgba(205, 49, 49, 0.12);
--shadow: 0 6px 24px rgba(40, 40, 40, 0.18);
--syntax-keyword: #0000ff;
--syntax-string: #a31515;
--syntax-number: #098658;
--syntax-comment: #008000;
--syntax-function: #795e26;
--syntax-type: #267f99;
--syntax-variable: #001080;
--syntax-meta: #af00db;
}
+10
View File
@@ -56,6 +56,8 @@ export interface Session {
last_usage?: Usage | null;
busy?: boolean;
owner_id?: string | null;
/** Craft: clean room (true) or deep (false); undefined = server default. */
clean_room?: boolean | null;
/** Listed because it was shared with the current user. */
shared?: boolean;
}
@@ -108,6 +110,7 @@ export type ServerEvent =
| { type: "error"; session_id: string; message: string }
| { type: "waiting"; session_id: string; reason: string | null }
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
| { type: "resync" };
@@ -120,8 +123,14 @@ export interface ModelInfo {
id: string;
name: string;
context?: number;
max_output?: number | null;
reasoning: boolean;
vision: boolean;
effort?: string | null;
input_price?: number | null;
output_price?: number | null;
cache_read_price?: number | null;
cache_write_price?: number | null;
}
export interface ProviderModels {
@@ -164,6 +173,7 @@ export interface User {
oidc: boolean;
disabled: boolean;
created_at: number;
monthly_quota?: number | null;
}
export interface Info {