feat(ui): terminal, charts and low-contrast text follow the selected theme

- The lane terminal (xterm) takes its palette from the selected DARK theme —
  it stays dark in light mode, as a terminal should — and repaints live when
  the theme changes (`client/src/lib/terminalTheme.ts`).
- Charts on the Dashboard and Workflows pages draw their grid, axes, labels
  and tooltips from theme tokens instead of hardcoded near-black/white, and
  the activity heatmap ramps from the page surface to the accent, so it reads
  on every theme instead of going near-black on light ones.
- Status/brand text that only worked on dark backgrounds
  (`text-*-300/400`) gets a darker light-mode shade (`text-*-700
  dark:text-*-300`) across sessions, conversations, workflows, config and
  settings. Categorical chart colours are kept.
This commit is contained in:
2026-09-24 09:50:44 +07:00
parent 6353e88c9e
commit 8ea40260ad
36 changed files with 919 additions and 635 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -182,7 +182,9 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
<div className="flex items-center gap-2.5 min-w-0 overflow-hidden">
<div
className={`w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 ${
isMain ? "bg-accent/15 text-accent" : "bg-violet-500/15 text-violet-400"
isMain
? "bg-accent/15 text-accent"
: "bg-violet-500/15 text-violet-700 dark:text-violet-400"
}`}
>
{isMain ? <Bot className="w-3.5 h-3.5" /> : <GitBranch className="w-3.5 h-3.5" />}
+1 -1
View File
@@ -379,7 +379,7 @@ function ScalarValue({ value }: { value: unknown }) {
if (value == null) return <span className="text-fg-muted italic">null</span>;
if (typeof value === "boolean") {
const color = value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10";
return (
<span className={`inline-block px-2 py-0.5 rounded border ${color}`}>{String(value)}</span>
+4 -4
View File
@@ -625,7 +625,7 @@ export function ImportHistory() {
<ResultStat
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
value={result.backfilled ?? 0}
color="text-blue-400"
color="text-blue-700 dark:text-blue-400"
/>
<ResultStat
label={t("import.result.skipped", { count: result.skipped })}
@@ -672,12 +672,12 @@ export function ImportHistory() {
<ResultStat
label={t("import.backupResult.events", { count: backupResult.events })}
value={backupResult.events}
color="text-violet-300"
color="text-violet-700 dark:text-violet-300"
/>
<ResultStat
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
value={backupResult.model_pricing}
color="text-cyan-300"
color="text-cyan-700 dark:text-cyan-300"
/>
</div>
<p className="text-[11px] text-fg-muted">
@@ -737,7 +737,7 @@ function ModeButton({
>
<div
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
active ? "text-blue-400" : "text-fg-secondary"
active ? "text-blue-700 dark:text-blue-400" : "text-fg-secondary"
}`}
>
{icon}
+12 -12
View File
@@ -95,21 +95,21 @@ function StatTile({
}) {
const palette = {
default: "border-surface-3 bg-surface-2 text-fg-secondary",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-200",
violet: "border-violet-500/20 bg-violet-500/5 text-violet-800 dark:text-violet-200",
emerald: "border-status-success/20 bg-status-success/5 text-status-success",
amber: "border-status-warning/20 bg-status-warning/5 text-status-warning",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-300",
rose: "border-rose-500/20 bg-rose-500/5 text-rose-800 dark:text-rose-200",
cyan: "border-cyan-500/20 bg-cyan-500/5 text-cyan-800 dark:text-cyan-200",
blue: "border-blue-600/20 bg-blue-600/5 text-blue-700 dark:text-blue-300",
}[tone];
const iconTone = {
default: "text-fg-muted",
violet: "text-violet-400",
violet: "text-violet-700 dark:text-violet-400",
emerald: "text-status-success",
amber: "text-status-warning",
rose: "text-rose-400",
cyan: "text-cyan-400",
rose: "text-rose-700 dark:text-rose-400",
cyan: "text-cyan-700 dark:text-cyan-400",
blue: "text-blue-500",
}[tone];
@@ -359,7 +359,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<div className="lg:col-span-2 rounded-lg border border-surface-3 bg-surface-2/60 p-3.5">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<Wrench className="w-3.5 h-3.5 text-violet-400" />
<Wrench className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
Top tools
</h3>
<span className="text-[10px] text-fg-muted font-mono">
@@ -418,7 +418,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
<>
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider flex items-center gap-1.5">
<GitBranch className="w-3.5 h-3.5 text-cyan-400" />
<GitBranch className="w-3.5 h-3.5 text-cyan-700 dark:text-cyan-400" />
Subagents
</h3>
<span className="text-[10px] text-fg-muted font-mono">{totalRuns} runs</span>
@@ -508,14 +508,14 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
label: "Cache read",
value: tokens.cache_read_tokens,
cls: "bg-sky-500",
text: "text-sky-300",
text: "text-sky-700 dark:text-sky-300",
},
{
key: "cache_write",
label: "Cache write",
value: tokens.cache_write_tokens,
cls: "bg-violet-500",
text: "text-violet-300",
text: "text-violet-700 dark:text-violet-300",
},
{
key: "input",
@@ -529,7 +529,7 @@ function TokenFlowBar({ tokens, total }: { tokens: SessionStats["tokens"]; total
label: "Output",
value: tokens.output_tokens,
cls: "bg-orange-500",
text: "text-orange-300",
text: "text-orange-700 dark:text-orange-300",
},
];
+1 -1
View File
@@ -91,7 +91,7 @@ function ReasonChip({ reason }: { reason: AwaitingReason }) {
className={`inline-flex items-center gap-1 rounded-full border px-1.5 text-[10px] font-medium leading-4 ${
cfg.urgent
? "bg-status-warning/15 border-status-warning/25 text-status-warning"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-400/90"
: "bg-yellow-500/10 border-yellow-500/20 text-yellow-700/90 dark:text-yellow-400/90"
}`}
>
<Icon className="w-2.5 h-2.5 flex-shrink-0" aria-hidden="true" />
+2 -2
View File
@@ -12,11 +12,11 @@ export function ScopeBadge({ scope }: { scope: string }) {
const { t } = useTranslation("ccConfig");
const color =
scope === "user"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: scope === "project"
? "bg-status-success/10 text-status-success border-status-success/30"
: scope === "project-local"
? "bg-violet-500/10 text-violet-300 border-violet-500/30"
? "bg-violet-500/10 text-violet-700 dark:text-violet-300 border-violet-500/30"
: "bg-surface-3 text-fg-secondary border-border";
const label =
scope === "project-local"
@@ -320,7 +320,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={linkM[2]!}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
>
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
</a>
@@ -337,7 +337,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
href={urlM[0]}
target="_blank"
rel="noopener noreferrer"
className="text-violet-300 hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70 break-all"
>
{urlM[0]}
</a>
@@ -428,7 +428,10 @@ export function MarkdownContent({ text, dense = false }: MarkdownContentProps) {
);
}
return (
<ul key={idx} className="list-disc pl-5 space-y-1 marker:text-violet-400/60">
<ul
key={idx}
className="list-disc pl-5 space-y-1 marker:text-violet-700/60 dark:marker:text-violet-400/60"
>
{b.items.map((item, i) => (
<li key={i} className="text-sm text-fg-secondary">
{renderListItem(item, `li${idx}-${i}`)}
@@ -82,25 +82,25 @@ const SENDER_STYLES: Record<
label: "User",
icon: User,
avatarRing:
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-300 ring-1 ring-blue-500/30",
"bg-gradient-to-br from-blue-600/30 to-cyan-500/20 text-blue-700 dark:text-blue-300 ring-1 ring-blue-500/30",
accentBar: "before:bg-blue-600/40",
headerText: "text-blue-300",
headerText: "text-blue-700 dark:text-blue-300",
},
assistant: {
label: "Assistant",
icon: Bot,
avatarRing:
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-200 ring-1 ring-violet-400/30",
"bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-violet-800 dark:text-violet-200 ring-1 ring-violet-400/30",
accentBar: "before:bg-violet-500/40",
headerText: "text-violet-200",
headerText: "text-violet-800 dark:text-violet-200",
},
orchestrator: {
label: "Main agent",
icon: Workflow,
avatarRing:
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-200 ring-1 ring-teal-400/30",
"bg-gradient-to-br from-teal-500/30 to-status-success/20 text-teal-800 dark:text-teal-200 ring-1 ring-teal-400/30",
accentBar: "before:bg-teal-500/40",
headerText: "text-teal-200",
headerText: "text-teal-800 dark:text-teal-200",
},
system: {
label: "System",
@@ -169,7 +169,7 @@ function SessionEventRow({ title, timestamp }: { title?: string; timestamp: stri
return (
<div className="flex items-center justify-center py-1">
<div className="inline-flex items-center gap-2 text-[11px] text-fg-secondary bg-surface-2/70 border border-surface-3 rounded-full px-3 py-1 max-w-full">
<Pencil className="w-3 h-3 text-violet-300/70 flex-shrink-0" />
<Pencil className="w-3 h-3 text-violet-700/70 dark:text-violet-300/70 flex-shrink-0" />
<span className="text-fg-muted">Renamed session →</span>
<span className="text-fg-secondary font-medium truncate">{title || "(untitled)"}</span>
{timestamp && (
@@ -253,11 +253,13 @@ function renderSegment(seg: TuiSegment, key: number): React.ReactNode {
<CollapsibleBlock
key={key}
text={seg.text}
icon={<ScrollText className="w-3.5 h-3.5 text-violet-400/60 flex-shrink-0" />}
icon={
<ScrollText className="w-3.5 h-3.5 text-violet-700/60 dark:text-violet-400/60 flex-shrink-0" />
}
title="Persisted output"
borderClass="border-violet-500/20"
bgClass="bg-violet-500/5"
textClass="text-violet-300/80"
textClass="text-violet-700/80 dark:text-violet-300/80"
/>
);
case "text": {
@@ -394,7 +396,9 @@ export function MessageList({ messages, loading }: MessageListProps) {
<span className="text-[10px] text-fg-muted font-mono inline-flex items-center gap-1">
<span className="text-status-success/70">↓ {fmt(msg.usage.input_tokens)}</span>
<span className="text-fg-muted">·</span>
<span className="text-orange-300/70">↑ {fmt(msg.usage.output_tokens)}</span>
<span className="text-orange-700/70 dark:text-orange-300/70">
↑ {fmt(msg.usage.output_tokens)}
</span>
</span>
)}
{msg.timestamp && (
@@ -413,11 +417,13 @@ export function MessageList({ messages, loading }: MessageListProps) {
<CollapsibleBlock
key={bIdx}
text={block.text}
icon={<ScrollText className="w-3.5 h-3.5 text-cyan-400/60 flex-shrink-0" />}
icon={
<ScrollText className="w-3.5 h-3.5 text-cyan-700/60 dark:text-cyan-400/60 flex-shrink-0" />
}
title="Task Notification"
borderClass="border-cyan-500/20"
bgClass="bg-cyan-500/5"
textClass="text-cyan-400/80"
textClass="text-cyan-700/80 dark:text-cyan-400/80"
/>
);
}
@@ -156,7 +156,7 @@ function renderInput(toolUse: TranscriptContent) {
return (
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-fg-secondary">
<FileText className="w-3.5 h-3.5 text-violet-400" />
<FileText className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
<span className="font-mono">{obj.file_path}</span>
{obj.replace_all === true && (
<span className="text-[10px] uppercase tracking-wider text-status-warning/80 bg-status-warning/10 border border-status-warning/20 rounded px-1.5 py-0.5">
@@ -178,7 +178,7 @@ function renderInput(toolUse: TranscriptContent) {
if (tool === "read" && typeof obj.file_path === "string") {
return (
<div className="flex items-center gap-1.5 text-xs text-fg-secondary bg-surface-4/40 border border-surface-3 rounded-md px-3 py-2">
<FileText className="w-3.5 h-3.5 text-sky-400 flex-shrink-0" />
<FileText className="w-3.5 h-3.5 text-sky-700 dark:text-sky-400 flex-shrink-0" />
<span className="font-mono break-all">{obj.file_path}</span>
{(typeof obj.offset === "number" || typeof obj.limit === "number") && (
<span className="text-fg-muted font-mono ml-auto flex-shrink-0">
@@ -198,7 +198,7 @@ function renderInput(toolUse: TranscriptContent) {
<span className="text-fg-muted font-mono uppercase tracking-wider text-[10px]">
Pattern
</span>
<code className="font-mono text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
<code className="font-mono text-cyan-700 dark:text-cyan-300 bg-surface-4 border border-surface-3 rounded px-1.5 py-0.5">
{obj.pattern}
</code>
</div>
+30 -30
View File
@@ -83,8 +83,8 @@ export interface ToolStyle {
const VIOLET: ToolStyle = {
Icon: Wrench,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
text: "text-violet-700 dark:text-violet-300",
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
};
@@ -92,99 +92,99 @@ const VIOLET: ToolStyle = {
const STYLES: Record<string, ToolStyle> = {
bash: {
Icon: Terminal,
text: "text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-300",
text: "text-emerald-700 dark:text-emerald-300",
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
bar: "bg-emerald-500/60",
border: "border-emerald-500/20",
},
read: {
Icon: FileText,
text: "text-sky-300",
chip: "bg-sky-500/15 text-sky-300",
text: "text-sky-700 dark:text-sky-300",
chip: "bg-sky-500/15 text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/60",
border: "border-sky-500/20",
},
write: {
Icon: FilePlus2,
text: "text-violet-300",
chip: "bg-violet-500/15 text-violet-300",
text: "text-violet-700 dark:text-violet-300",
chip: "bg-violet-500/15 text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/60",
border: "border-violet-500/20",
},
edit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
text: "text-amber-700 dark:text-amber-300",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
multiedit: {
Icon: FilePen,
text: "text-amber-300",
chip: "bg-amber-500/15 text-amber-300",
text: "text-amber-700 dark:text-amber-300",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
bar: "bg-amber-500/60",
border: "border-amber-500/20",
},
grep: {
Icon: Search,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
text: "text-cyan-700 dark:text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
glob: {
Icon: FolderTree,
text: "text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-300",
text: "text-cyan-700 dark:text-cyan-300",
chip: "bg-cyan-500/15 text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/60",
border: "border-cyan-500/20",
},
webfetch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
text: "text-blue-700 dark:text-blue-300",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
websearch: {
Icon: Globe,
text: "text-blue-300",
chip: "bg-blue-500/15 text-blue-300",
text: "text-blue-700 dark:text-blue-300",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
bar: "bg-blue-500/60",
border: "border-blue-500/20",
},
task: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
text: "text-pink-700 dark:text-pink-300",
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
agent: {
Icon: Bot,
text: "text-pink-300",
chip: "bg-pink-500/15 text-pink-300",
text: "text-pink-700 dark:text-pink-300",
chip: "bg-pink-500/15 text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/60",
border: "border-pink-500/20",
},
todowrite: {
Icon: ListTodo,
text: "text-rose-300",
chip: "bg-rose-500/15 text-rose-300",
text: "text-rose-700 dark:text-rose-300",
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/60",
border: "border-rose-500/20",
},
schedulewakeup: {
Icon: Clock,
text: "text-orange-300",
chip: "bg-orange-500/15 text-orange-300",
text: "text-orange-700 dark:text-orange-300",
chip: "bg-orange-500/15 text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/60",
border: "border-orange-500/20",
},
skill: {
Icon: Sparkles,
text: "text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-300",
text: "text-fuchsia-700 dark:text-fuchsia-300",
chip: "bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/60",
border: "border-fuchsia-500/20",
},
@@ -289,7 +289,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
let newLine = hunk.newStart;
return (
<div>
<div className="px-3 py-1 text-[10px] text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
<div className="px-3 py-1 text-[10px] text-cyan-700 dark:text-cyan-300 bg-cyan-500/10 border-y border-cyan-500/20 font-mono">
@@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
</div>
<table className="w-full border-collapse">
@@ -301,7 +301,7 @@ function HunkView({ hunk }: { hunk: DiffHunk }) {
const showNew = kind !== "remove";
const rowBg =
kind === "add"
? "bg-green-500/10 text-green-200"
? "bg-green-500/10 text-green-800 dark:text-green-200"
: kind === "remove"
? "bg-status-danger/10 text-status-danger"
: "text-fg-secondary";
@@ -372,7 +372,7 @@ function ValueCell({ value }: { value: unknown }) {
<span
className={`inline-block px-2 py-0.5 rounded border text-[11px] font-mono ${
value
? "text-green-400 border-green-500/30 bg-green-500/10"
? "text-green-700 dark:text-green-400 border-green-500/30 bg-green-500/10"
: "text-fg-secondary border-border-light/30 bg-surface-4/10"
}`}
>
@@ -445,7 +445,7 @@ export function MatchList({ matches }: { matches: GrepMatch[] }) {
<ul className="divide-y divide-border border border-border rounded overflow-hidden text-[11px] max-h-80 overflow-y-auto font-mono">
{matches.map((m, i) => (
<li key={i} className="px-3 py-1 text-fg-secondary break-all">
{m.file && <span className="text-cyan-300">{m.file}</span>}
{m.file && <span className="text-cyan-700 dark:text-cyan-300">{m.file}</span>}
{m.line != null && <span className="text-fg-muted">:{m.line}</span>}
{m.text && <span className="text-fg-secondary">: {m.text}</span>}
</li>
@@ -99,7 +99,7 @@ export function FolderBrowseModal({
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs text-fg-primary hover:bg-surface-2"
>
{entry.isGitRepo ? (
<FolderGit2 className="h-3.5 w-3.5 text-blue-400" />
<FolderGit2 className="h-3.5 w-3.5 text-blue-700 dark:text-blue-400" />
) : (
<FolderOpen className="h-3.5 w-3.5 text-fg-muted" />
)}
+2 -2
View File
@@ -58,7 +58,7 @@ function StatusPill({
const colors: Record<string, string> = {
running: "bg-status-success/10 text-status-success border-status-success/30",
gone: "bg-surface-3 text-fg-secondary border-border",
completed: "bg-sky-500/10 text-sky-300 border-sky-500/30",
completed: "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30",
error: "bg-status-danger/10 text-status-danger border-status-danger/30",
killed: "bg-surface-3 text-fg-secondary border-border",
abandoned: "bg-surface-3 text-fg-secondary border-border",
@@ -574,7 +574,7 @@ function UnifiedRunRowView({
)}
{row.external && (
<span
className="text-[10px] font-semibold text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
className="text-[10px] font-semibold text-amber-700 dark:text-amber-300 bg-amber-500/10 border border-amber-500/25 px-1.5 py-0.5 rounded-full"
title={t("runs.externalHint")}
>
{t("runs.externalBadge")}
+1 -1
View File
@@ -691,7 +691,7 @@ function SessionPicker({
s.status === "active"
? "bg-status-success/10 text-status-success border-status-success/30"
: s.status === "completed"
? "bg-sky-500/10 text-sky-300 border-sky-500/30"
? "bg-sky-500/10 text-sky-700 dark:text-sky-300 border-sky-500/30"
: s.status === "error"
? "bg-status-danger/10 text-status-danger border-status-danger/30"
: "bg-surface-3 text-fg-secondary border-border"
+28 -2
View File
@@ -9,13 +9,17 @@
* The socket auto-reconnects: a server restart kills the attach PTY, and
* without a retry the pane silently freezes on its last painted frame — it
* still LOOKS live, but no keystroke or mouse report reaches tmux again.
* Always painted dark, in the palette of the user's chosen DARK theme
* (`lib/terminalTheme.ts`), recoloured live without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useRef } from "react";
import { useEffect, useMemo, useRef } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { CanvasAddon } from "@xterm/addon-canvas";
import "@xterm/xterm/css/xterm.css";
import { useTheme } from "../../hooks/useTheme";
import { terminalThemeFor } from "../../lib/terminalTheme";
interface TerminalViewProps {
runId: string;
@@ -33,6 +37,14 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
// re-attach on each one.
const onExitRef = useRef(onExit);
onExitRef.current = onExit;
// Always the DARK theme's palette, even in light mode: Claude Code's TUI
// assumes a dark background. Read through a ref at construction so a theme
// change never re-runs the attach effect; the effect below recolours live.
const darkTheme = useTheme().themeFor.dark;
const palette = useMemo(() => terminalThemeFor(darkTheme), [darkTheme]);
const paletteRef = useRef(palette);
paletteRef.current = palette;
const termRef = useRef<Terminal | null>(null);
useEffect(() => {
// An explicit fontFamily pins every glyph to one bundled webfont. Left
@@ -56,7 +68,9 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
fontSize: 13,
cursorBlink: true,
fontFamily: '"JetBrains Mono", monospace',
theme: paletteRef.current,
});
termRef.current = term;
const fit = new FitAddon();
term.loadAddon(fit);
if (containerRef.current) term.open(containerRef.current);
@@ -225,8 +239,20 @@ export function TerminalView({ runId, wsBaseUrl, onExit }: TerminalViewProps) {
ws?.close();
canvas?.dispose();
term.dispose();
termRef.current = null;
};
}, [runId, wsBaseUrl]);
return <div ref={containerRef} className="h-full w-full" data-testid="terminal-view" />;
useEffect(() => {
if (termRef.current) termRef.current.options.theme = palette;
}, [palette]);
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ backgroundColor: palette.background }}
data-testid="terminal-view"
/>
);
}
@@ -3,11 +3,12 @@
* @description Tests for the xterm.js-backed terminal view: verifies it opens
* a WS connection to the right URL, writes incoming binary frames to the
* mocked terminal, forwards typed input as outgoing binary frames, and
* re-attaches after the socket drops (server restart) but not after `exit`.
* re-attaches after the socket drops (server restart) but not after `exit`,
* and recolours live on a dark-theme change without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { render, cleanup, act } from "@testing-library/react";
import { TerminalView } from "../TerminalView";
const writeMock = vi.fn();
@@ -26,9 +27,13 @@ const termState = {
rows: 28,
};
const onResizeHandlers: Array<() => void> = [];
const termOptions: { theme?: unknown } = {};
const ctorArgs: Array<{ theme?: unknown }> = [];
vi.mock("@xterm/xterm", () => ({
Terminal: vi.fn().mockImplementation(() => ({
Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({
...(ctorArgs.push(opts), {}),
options: termOptions,
get modes() {
return termState.modes;
},
@@ -58,6 +63,10 @@ vi.mock("@xterm/xterm", () => ({
focus: focusMock,
})),
}));
// jsdom loads no stylesheet, so stand in for the CSS-variable probe.
vi.mock("../../../lib/terminalTheme", () => ({
terminalThemeFor: (id: string) => ({ background: `bg-${id}` }),
}));
vi.mock("@xterm/addon-fit", () => ({
FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })),
}));
@@ -103,6 +112,10 @@ describe("TerminalView", () => {
loadAddonMock.mockClear();
openMock.mockClear();
focusMock.mockClear();
disposeMock.mockClear();
ctorArgs.length = 0;
delete termOptions.theme;
localStorage.clear();
});
afterEach(cleanup);
@@ -126,6 +139,21 @@ describe("TerminalView", () => {
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
});
it("paints the chosen DARK theme and recolours live without re-attaching", () => {
localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark
localStorage.setItem("agenthub.theme.dark", "apple-dark");
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" });
act(() => {
localStorage.setItem("agenthub.theme.dark", "darcula");
window.dispatchEvent(new Event("agenthub-theme"));
});
expect(termOptions.theme).toEqual({ background: "bg-darcula" });
expect(MockWebSocket.instances).toHaveLength(1);
expect(disposeMock).not.toHaveBeenCalled();
});
it("writes incoming WS data to the terminal", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
@@ -151,7 +151,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x2", innerW)
.attr("y1", (d) => yScale(d))
.attr("y2", (d) => yScale(d))
.attr("stroke", "#2a2a3d")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1);
// Y axis (sessions)
@@ -166,7 +166,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
)
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -176,7 +176,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.call(d3.axisBottom(xScale).tickSize(0).tickPadding(8))
.call((ax) => ax.select(".domain").remove())
.selectAll("text")
.attr("fill", "#9ca3af")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-family", "Inter, sans-serif");
@@ -185,7 +185,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", innerW / 2)
.attr("y", innerH + 38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -196,7 +196,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", -innerH / 2)
.attr("y", -38)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", 10)
.attr("font-weight", 500)
.attr("font-family", "Inter, sans-serif")
@@ -229,7 +229,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("x", bx + bw / 2)
.attr("y", by - 5)
.attr("text-anchor", "middle")
.attr("fill", "#a5b4fc")
.attr("fill", "rgb(var(--fg-secondary))")
.attr("font-size", 10)
.attr("font-weight", "600")
.attr("font-family", "Inter, sans-serif")
@@ -242,7 +242,7 @@ function renderHistogram(svg: SVGSVGElement, histo: HistogramBucket[], opts: His
.attr("y", innerH - 1)
.attr("width", bw)
.attr("height", 1)
.attr("fill", "#2a2a3d");
.attr("fill", "rgb(var(--border))");
}
// Transparent, full-height hover target on top of the bar.
@@ -364,12 +364,12 @@ export function CompactionImpact({ data }: CompactionImpactProps) {
label={t("compaction.sessionsAffected")}
value={affected.toLocaleString()}
sub={t("compaction.ofTotal", { total: data.totalSessions.toLocaleString() })}
accent="text-violet-300"
accent="text-violet-700 dark:text-violet-300"
/>
<StatBox
label={t("compaction.avgPerSession")}
value={avgPerSession.toFixed(1)}
accent="text-blue-400"
accent="text-blue-700 dark:text-blue-400"
/>
<StatBox
label={t("compaction.peakSession")}
@@ -92,7 +92,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#10b981"
stroke="rgb(var(--status-success))"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
@@ -174,7 +174,7 @@ export function ErrorPropagationMap({ data }: ErrorPropagationMapProps) {
</div>
<span
className="text-xs font-semibold tabular-nums w-7 text-right transition-colors"
style={{ color: isHovered ? color : "#9ca3af" }}
style={{ color: isHovered ? color : "rgb(var(--fg-secondary))" }}
>
{d.count}
</span>
@@ -587,7 +587,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("x", (_, i) => layerXPositions[i] ?? 0)
.attr("y", 22)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "10px")
.attr("font-weight", "500")
.attr("letter-spacing", "0.08em")
@@ -605,7 +605,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.attr("y1", 32)
.attr("x2", sepX)
.attr("y2", svgHeight - PADDING_BOTTOM + 8)
.attr("stroke", "#1f1f30")
.attr("stroke", "rgb(var(--border))")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4 4");
}
@@ -651,7 +651,7 @@ export function OrchestrationDAG({ data, onNodeClick, selectedNode }: Orchestrat
.append("path")
.attr("d", path)
.attr("fill", "none")
.attr("stroke", isZero ? "#2a2a3d" : "#4f46e5")
.attr("stroke", isZero ? "rgb(var(--border-light))" : "#4f46e5")
.attr("stroke-width", isZero ? 1 : stroke)
.attr("stroke-opacity", isZero ? 0.3 : 0.55)
.attr("stroke-linecap", "round");
@@ -262,7 +262,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.range([MIN_BUBBLE_R, MAX_BUBBLE_R]);
// Grid lines
const gridColor = "#2a2a3d";
const gridColor = "rgb(var(--border))";
g.append("g")
.attr("class", "grid-x")
@@ -303,9 +303,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.tickFormat((d) => fmtXTick(d as number))
)
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// X axis label
@@ -313,7 +313,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", innerW / 2)
.attr("y", innerH + 44)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.duration"));
@@ -321,9 +321,9 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
g.append("g")
.call(d3.axisLeft(yScale).ticks(5).tickFormat(d3.format("d")))
.call((sel) => {
sel.select(".domain").attr("stroke", "#363650");
sel.selectAll(".tick line").attr("stroke", "#363650");
sel.selectAll(".tick text").attr("fill", "#6b7280").attr("font-size", "11");
sel.select(".domain").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick line").attr("stroke", "rgb(var(--border-light))");
sel.selectAll(".tick text").attr("fill", "rgb(var(--fg-muted))").attr("font-size", "11");
});
// Y axis label
@@ -332,7 +332,7 @@ export function SessionComplexityScatter({ data, onSessionClick }: SessionComple
.attr("x", -innerH / 2)
.attr("y", -40)
.attr("text-anchor", "middle")
.attr("fill", "#6b7280")
.attr("fill", "rgb(var(--fg-muted))")
.attr("font-size", "11")
.text(t("complexity.agentCount"));
@@ -75,7 +75,7 @@ type AgentNode = SessionDrillInData["tree"][number];
function statusColor(status: string): string {
switch (status) {
case "completed":
return "text-violet-400 bg-violet-500/10 border-violet-500/20";
return "text-violet-700 dark:text-violet-400 bg-violet-500/10 border-violet-500/20";
case "working":
return "text-status-success bg-status-success/10 border-status-success/20";
case "error":
@@ -83,7 +83,7 @@ function statusColor(status: string): string {
case "active":
return "text-status-success bg-status-success/10 border-status-success/20";
case "waiting":
return "text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
return "text-yellow-700 dark:text-yellow-400 bg-yellow-500/10 border-yellow-500/20";
default:
return "text-fg-secondary bg-surface-4/10 border-border-light/20";
}
@@ -186,7 +186,7 @@ function TreeNode({ node, depth }: TreeNodeProps) {
{/* Name */}
<span
className={`text-sm font-medium truncate ${isMain ? "text-indigo-300" : "text-fg-secondary"}`}
className={`text-sm font-medium truncate ${isMain ? "text-indigo-700 dark:text-indigo-300" : "text-fg-secondary"}`}
>
{node.name}
</span>
@@ -255,7 +255,7 @@ function ToolTimeline({ events }: ToolTimelineProps) {
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-surface-4 transition-colors"
>
{/* Tool pill */}
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
<span className="flex-shrink-0 inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border border-indigo-500/20 whitespace-nowrap">
{ev.tool_name ?? ev.event_type}
</span>
@@ -286,8 +286,8 @@ interface EventSequenceProps {
const EVENT_TYPE_COLOR: Record<string, string> = {
tool_use: "text-blue-500",
tool_result: "text-status-success",
agent_start: "text-indigo-400",
agent_stop: "text-violet-400",
agent_start: "text-indigo-700 dark:text-indigo-400",
agent_stop: "text-violet-700 dark:text-violet-400",
compaction: "text-status-warning",
error: "text-status-danger",
};
@@ -121,13 +121,13 @@ function statusClass(status: string): string {
// (e.g. Scout / Verify / Synthesize, or Explain / Interview / Gotcha) reads
// as its own color in both the filter row and the result label chips.
const PHASE_PALETTE = [
"bg-violet-500/15 text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-300 border-sky-500/40",
"bg-violet-500/15 text-violet-700 dark:text-violet-300 border-violet-500/40",
"bg-sky-500/15 text-sky-700 dark:text-sky-300 border-sky-500/40",
"bg-status-warning/15 text-status-warning border-status-warning/40",
"bg-status-success/15 text-status-success border-status-success/40",
"bg-rose-500/15 text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-300 border-fuchsia-500/40",
"bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/40",
"bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/40",
"bg-fuchsia-500/15 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-500/40",
];
function phaseColor(phaseTitles: string[], title: string | null | undefined): string {
@@ -321,7 +321,7 @@ export function WorkflowRunsPanel({
if (!controlled && loading) {
return (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-fg-muted">
<Loader2 className="w-4 h-4 animate-spin text-violet-400" />
<Loader2 className="w-4 h-4 animate-spin text-violet-700 dark:text-violet-400" />
<span className="animate-pulse">{t("runs.loading")}</span>
</div>
);
@@ -365,7 +365,7 @@ export function WorkflowRunsPanel({
{running ? (
<Loader2 className="w-4 h-4 text-status-warning flex-shrink-0 animate-spin" />
) : (
<Workflow className="w-4 h-4 text-violet-400 flex-shrink-0" />
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
@@ -393,7 +393,7 @@ export function WorkflowRunsPanel({
<Link
to={`/sessions/${encodeURIComponent(run.session_id)}`}
onClick={(e) => e.stopPropagation()}
className="text-fg-muted hover:text-violet-400 transition-colors flex-shrink-0"
className="text-fg-muted hover:text-violet-700 dark:hover:text-violet-400 transition-colors flex-shrink-0"
title={t("runs.openSession")}
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -565,7 +565,7 @@ export function WorkflowRunsPanel({
<span>{t("runs.tools", { count: a.toolCalls || 0 })}</span>
{a.durationMs != null && <span>{formatMs(a.durationMs)}</span>}
{ts?.loading && (
<span className="flex items-center gap-1 text-violet-400">
<span className="flex items-center gap-1 text-violet-700 dark:text-violet-400">
<Loader2 className="w-3 h-3 animate-spin" />
{t("runs.loadingFull")}
</span>
@@ -71,7 +71,7 @@ function formatDurationSec(sec: number): string {
function successRateColor(rate: number): string {
if (rate > 90) return "text-status-success";
if (rate > 70) return "text-yellow-400";
if (rate > 70) return "text-yellow-700 dark:text-yellow-400";
return "text-status-danger";
}
@@ -325,7 +325,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgAgentDepth")}
value={stats.avgDepth.toFixed(1)}
icon={GitFork}
accentClass="text-indigo-400"
accentClass="text-indigo-700 dark:text-indigo-400"
calculationKey="stats.tooltip.calc.depth"
interp={interpAvgDepth(stats.avgDepth)}
metricPhraseKey="stats.tooltip.phrase.depth"
@@ -352,7 +352,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.mostCommonFlow")}
value={topFlowLabel}
icon={ArrowRightLeft}
accentClass="text-violet-400"
accentClass="text-violet-700 dark:text-violet-400"
calculationKey="stats.tooltip.calc.topFlow"
interp={interpTopFlow(topFlow?.source ?? null, topFlow?.target ?? null)}
metricPhraseKey="stats.tooltip.phrase.topFlow"
@@ -361,7 +361,7 @@ export function WorkflowStats({ stats }: WorkflowStatsProps) {
label={t("stats.avgCompactions")}
value={stats.avgCompactions.toFixed(1)}
icon={Layers}
accentClass="text-cyan-400"
accentClass="text-cyan-700 dark:text-cyan-400"
calculationKey="stats.tooltip.calc.compactions"
interp={interpAvgCompactions(stats.avgCompactions)}
metricPhraseKey="stats.tooltip.phrase.compactions"
@@ -0,0 +1,71 @@
/**
* @file terminalTheme.test.ts
* @description Tests for the xterm theme builder: CSS-variable RGB triplets
* become hex, status/accent map to the ANSI hues, bright twins are lighter,
* and the probe resolves a named dark theme's variables.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { buildTerminalTheme, terminalThemeFor } from "../terminalTheme";
const vars: Record<string, string> = {
"--surface-0": " 17 17 19",
"--surface-3": "39 42 45",
"--fg-primary": "237 238 240",
"--fg-secondary": "176 180 186",
"--fg-muted": "105 110 119",
"--border-light": "67 72 78",
"--accent": "0 144 255",
"--status-success": "61 214 140",
"--status-danger": "255 149 146",
"--status-warning": "255 202 22",
};
describe("buildTerminalTheme", () => {
it("maps theme tokens onto the xterm palette", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
expect(t.background).toBe("#111113");
expect(t.foreground).toBe("#edeef0");
expect(t.cursor).toBe("#edeef0");
expect(t.selectionBackground).toBe("#43484e");
expect(t.red).toBe("#ff9592");
expect(t.green).toBe("#3dd68c");
expect(t.yellow).toBe("#ffca16");
expect(t.blue).toBe("#0090ff");
expect(t.black).toBe("#43484e");
expect(t.brightBlack).toBe("#696e77");
expect(t.brightWhite).toBe("#edeef0");
});
it("makes each bright twin lighter than its base colour", () => {
const t = buildTerminalTheme((n) => vars[n] ?? "");
const sum = (hex: string) =>
[1, 3, 5].reduce((a, i) => a + parseInt(hex.slice(i, i + 2), 16), 0);
for (const c of ["red", "green", "yellow", "blue", "magenta", "cyan"] as const) {
const base = t[c]!;
const bright = t[`bright${c[0]!.toUpperCase()}${c.slice(1)}` as keyof typeof t] as string;
expect(sum(bright)).toBeGreaterThan(sum(base));
}
});
it("leaves a colour unset when its variable is missing, so xterm keeps its default", () => {
const t = buildTerminalTheme(() => "");
expect(t.background).toBeUndefined();
expect(t.red).toBeUndefined();
});
});
describe("terminalThemeFor", () => {
it("resolves variables on a dark probe scoped to the theme id, then removes it", () => {
const style = document.createElement("style");
style.textContent = `.dark { --surface-0: 1 2 3; } .dark[data-theme="darcula"] { --surface-0: 30 31 32; }`;
document.head.appendChild(style);
try {
expect(terminalThemeFor("default").background).toBe("#010203");
expect(terminalThemeFor("darcula").background).toBe("#1e1f20");
expect(document.body.children).toHaveLength(0);
} finally {
style.remove();
}
});
});
+94
View File
@@ -0,0 +1,94 @@
/**
* @file terminalTheme.ts
* @description The xterm colour theme for lane terminals. The terminal is
* ALWAYS dark — Claude Code's TUI picks its own colours for a dark background,
* so a light terminal would make its text unreadable — and is painted with the
* palette of the DARK theme the user picked (`themeFor.dark`). Colours are read
* from the CSS variables in `index.css` on a hidden probe carrying
* `class="dark" data-theme=<id>`, so no hex is duplicated here.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import type { ITheme } from "@xterm/xterm";
/** Reads one CSS variable (an RGB triplet like `17 17 19`) by name. */
export type VarReader = (name: string) => string;
function toHex(triplet: string): string | undefined {
const parts = triplet
.trim()
.split(/[\s,]+/)
.map(Number);
if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) return undefined;
return "#" + parts.map((n) => Math.round(n).toString(16).padStart(2, "0")).join("");
}
/** Mix a hex colour toward white by `amount` (0..1) — the ANSI "bright" twin. */
function lighten(hex: string | undefined, amount: number): string | undefined {
if (!hex) return undefined;
const rgb = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
return (
"#" +
rgb
.map((c) =>
Math.round(c + (255 - c) * amount)
.toString(16)
.padStart(2, "0")
)
.join("")
);
}
/**
* Build an xterm theme from a theme's CSS variables. red/green/yellow come from
* `--status-*`, blue from `--accent`, black/white from the surface/fg ramp.
* Magenta and cyan stay the classic ANSI hues: no token keeps that hue in every
* theme (darcula's keyword is orange), and a program printing "magenta" should
* get magenta.
*/
export function buildTerminalTheme(read: VarReader): ITheme {
const v = (name: string) => toHex(read(name));
const red = v("--status-danger");
const green = v("--status-success");
const yellow = v("--status-warning");
const blue = v("--accent");
const magenta = "#c678dd";
const cyan = "#56b6c2";
return {
background: v("--surface-0"),
foreground: v("--fg-primary"),
cursor: v("--fg-primary"),
cursorAccent: v("--surface-0"),
selectionBackground: v("--border-light"),
black: v("--border-light"),
red,
green,
yellow,
blue,
magenta,
cyan,
white: v("--fg-secondary"),
brightBlack: v("--fg-muted"),
brightRed: lighten(red, 0.25),
brightGreen: lighten(green, 0.25),
brightYellow: lighten(yellow, 0.25),
brightBlue: lighten(blue, 0.25),
brightMagenta: lighten(magenta, 0.25),
brightCyan: lighten(cyan, 0.25),
brightWhite: v("--fg-primary"),
};
}
/** Resolve the dark theme `id`'s variables on a hidden probe and build the theme. */
export function terminalThemeFor(id: string): ITheme {
const probe = document.createElement("div");
probe.className = "dark";
if (id !== "default") probe.dataset.theme = id;
probe.style.display = "none";
document.body.appendChild(probe);
try {
const style = getComputedStyle(probe);
return buildTerminalTheme((name) => style.getPropertyValue(name));
} finally {
probe.remove();
}
}
+9 -9
View File
@@ -2805,7 +2805,7 @@ export const STATUS_CONFIG: Record<
// Green: the agent is actively running a tool / doing work.
working: {
labelKey: "common:status.working",
color: "text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
},
@@ -2813,21 +2813,21 @@ export const STATUS_CONFIG: Record<
// on user input - attention may be required.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
},
// Violet: the agent finished cleanly.
completed: {
labelKey: "common:status.completed",
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
},
// Red: the agent ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-400",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
},
@@ -3013,28 +3013,28 @@ export const SESSION_STATUS_CONFIG: Record<
// Green: the session is live and running.
active: {
labelKey: "common:status.active",
color: "text-emerald-400",
color: "text-emerald-700 dark:text-emerald-400",
bg: "bg-emerald-500/10 border-emerald-500/20",
dot: "bg-emerald-400",
},
// Yellow: blocked on user input via the AWAITING_STATUS overlay.
waiting: {
labelKey: "common:status.waiting",
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
bg: "bg-yellow-500/10 border-yellow-500/20",
dot: "bg-yellow-400",
},
// Violet: the session ended cleanly (SessionEnd).
completed: {
labelKey: "common:status.completed",
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
bg: "bg-violet-500/10 border-violet-500/20",
dot: "bg-violet-400",
},
// Red: the session ended in an error state.
error: {
labelKey: "common:status.error",
color: "text-red-400",
color: "text-red-700 dark:text-red-400",
bg: "bg-red-500/10 border-red-500/20",
dot: "bg-red-400",
},
@@ -3044,7 +3044,7 @@ export const SESSION_STATUS_CONFIG: Record<
// Muted slate distinguishes "given up / faded out" from yellow Waiting
// (attention required).
labelKey: "common:status.abandoned",
color: "text-slate-400",
color: "text-slate-600 dark:text-slate-400",
bg: "bg-slate-500/10 border-slate-500/20",
dot: "bg-slate-400",
},
+3 -1
View File
@@ -341,7 +341,9 @@ export function ActivityFeed() {
<p className="text-xs text-fg-muted">
{t("subtitle")}
{paused && (
<span className="ml-2 text-yellow-400">{t("paused", { count: bufferCount })}</span>
<span className="ml-2 text-yellow-700 dark:text-yellow-400">
{t("paused", { count: bufferCount })}
</span>
)}
</p>
</div>
+14 -14
View File
@@ -970,7 +970,7 @@ type Tone =
const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring: string }> = {
sky: {
iconBg: "bg-sky-500/10",
iconText: "text-sky-300",
iconText: "text-sky-700 dark:text-sky-300",
bar: "bg-sky-500/40",
ring: "ring-sky-500/20",
},
@@ -982,7 +982,7 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
},
violet: {
iconBg: "bg-violet-500/10",
iconText: "text-violet-300",
iconText: "text-violet-700 dark:text-violet-300",
bar: "bg-violet-500/40",
ring: "ring-violet-500/20",
},
@@ -994,37 +994,37 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
},
fuchsia: {
iconBg: "bg-fuchsia-500/10",
iconText: "text-fuchsia-300",
iconText: "text-fuchsia-700 dark:text-fuchsia-300",
bar: "bg-fuchsia-500/40",
ring: "ring-fuchsia-500/20",
},
cyan: {
iconBg: "bg-cyan-500/10",
iconText: "text-cyan-300",
iconText: "text-cyan-700 dark:text-cyan-300",
bar: "bg-cyan-500/40",
ring: "ring-cyan-500/20",
},
pink: {
iconBg: "bg-pink-500/10",
iconText: "text-pink-300",
iconText: "text-pink-700 dark:text-pink-300",
bar: "bg-pink-500/40",
ring: "ring-pink-500/20",
},
indigo: {
iconBg: "bg-indigo-500/10",
iconText: "text-indigo-300",
iconText: "text-indigo-700 dark:text-indigo-300",
bar: "bg-indigo-500/40",
ring: "ring-indigo-500/20",
},
orange: {
iconBg: "bg-orange-500/10",
iconText: "text-orange-300",
iconText: "text-orange-700 dark:text-orange-300",
bar: "bg-orange-500/40",
ring: "ring-orange-500/20",
},
teal: {
iconBg: "bg-teal-500/10",
iconText: "text-teal-300",
iconText: "text-teal-700 dark:text-teal-300",
bar: "bg-teal-500/40",
ring: "ring-teal-500/20",
},
@@ -1036,7 +1036,7 @@ const TONES: Record<Tone, { iconBg: string; iconText: string; bar: string; ring:
},
rose: {
iconBg: "bg-rose-500/10",
iconText: "text-rose-300",
iconText: "text-rose-700 dark:text-rose-300",
bar: "bg-rose-500/40",
ring: "ring-rose-500/20",
},
@@ -2721,7 +2721,7 @@ function CurrentConfigPanel({ sources }: { sources: CcSettingsSource[] }) {
return (
<div className="rounded-lg border border-border bg-surface-2">
<div className="border-b border-border px-4 py-2.5 flex items-center gap-2">
<SettingsIcon className="w-3.5 h-3.5 text-violet-300/80" />
<SettingsIcon className="w-3.5 h-3.5 text-violet-700/80 dark:text-violet-300/80" />
<span className="text-sm font-medium text-fg-primary">Current configuration</span>
<span className="text-[11px] text-fg-muted ml-auto">
{setCount} option{setCount !== 1 ? "s" : ""} set · the rest use defaults
@@ -3233,7 +3233,7 @@ function renderMemoryIndex(
type="button"
onClick={() => onJump(capturedTarget)}
title={`${jumpTitle}: ${normalizeMemoryTarget(capturedTarget)}`}
className="text-teal-300 hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
className="text-teal-700 dark:text-teal-300 hover:text-teal-800 dark:hover:text-teal-200 underline decoration-dotted underline-offset-2 hover:decoration-solid focus:outline-none focus:ring-1 focus:ring-teal-400/60 rounded-sm"
>
{label}
</button>
@@ -3386,7 +3386,7 @@ function MemoryPanel({
{totalAuto > 0 && (
<section className="pt-1">
<div className="flex items-center gap-2 mb-1">
<BookOpen className="w-3.5 h-3.5 text-teal-300" />
<BookOpen className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300" />
<h3 className="text-[11px] font-semibold uppercase tracking-wider text-fg-secondary">
{t("memory.autoTitle")}
</h3>
@@ -3499,7 +3499,7 @@ function MemoryProjectGroup({
open ? "" : "-rotate-90"
}`}
/>
<FolderTree className="w-3.5 h-3.5 text-teal-300 flex-shrink-0" />
<FolderTree className="w-3.5 h-3.5 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-xs text-fg-secondary truncate flex-1 min-w-0">
{project}
</span>
@@ -3618,7 +3618,7 @@ function MemoryIndexCard({
return (
<div className="rounded-md border border-teal-500/20 bg-teal-500/5">
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-teal-500/15">
<BookOpen className="w-3 h-3 text-teal-300 flex-shrink-0" />
<BookOpen className="w-3 h-3 text-teal-700 dark:text-teal-300 flex-shrink-0" />
<span className="font-mono text-[11px] text-fg-secondary truncate flex-1 min-w-0">
{item.name}
</span>
+54 -42
View File
@@ -212,27 +212,16 @@ function useTooltip() {
return { show, move, hide, node };
}
function cellColor(count: number, max: number) {
if (count === 0) return "#161625";
// Log scale + RGB interpolation across a wide color ramp for maximum perceptual range
/** Heatmap cell fill: the page surface mixed toward the accent on a log
* scale. Built from theme tokens so the ramp reads surface → accent in every
* theme; the old fixed indigo ramp went near-black on light themes. Exported
* for its test. */
export function cellColor(count: number, max: number) {
if (count === 0) return "rgb(var(--surface-0))";
const t = Math.log(count + 1) / Math.log(Math.max(max, 1) + 1);
// Ramp: near-black indigo → deep indigo → bright indigo → lavender
type RGB = [number, number, number];
const stops: RGB[] = [
[22, 20, 60], // near-black indigo
[55, 48, 163], // deep indigo
[99, 102, 241], // bright indigo
[199, 210, 254], // lavender
];
const scaled = t * (stops.length - 1);
const lo = Math.min(Math.floor(scaled), stops.length - 2);
const frac = scaled - lo;
const [r1, g1, b1]: RGB = stops[lo] as RGB;
const [r2, g2, b2]: RGB = stops[lo + 1] as RGB;
const r = Math.round(r1 + (r2 - r1) * frac);
const g = Math.round(g1 + (g2 - g1) * frac);
const b = Math.round(b1 + (b2 - b1) * frac);
return `rgb(${r},${g},${b})`;
// Floor at 20% so a single event still reads as distinct from an empty day.
const pct = Math.round(20 + 80 * t);
return `color-mix(in srgb, rgb(var(--accent)) ${pct}%, rgb(var(--surface-0)))`;
}
function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }>> }) {
@@ -352,7 +341,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
height: 13,
borderRadius: 2,
backgroundColor: cellColor(cell.count, maxCount),
border: "1px solid rgba(255,255,255,0.04)",
border: "1px solid rgb(var(--border) / 0.4)",
flexShrink: 0,
cursor: "default",
}}
@@ -374,7 +363,7 @@ function Heatmap({ weeks }: { weeks: Array<Array<{ date: string; count: number }
height: 13,
borderRadius: 2,
backgroundColor: cellColor(v, maxCount),
border: "1px solid rgba(255,255,255,0.06)",
border: "1px solid rgb(var(--border) / 0.5)",
}}
/>
);
@@ -593,7 +582,14 @@ function DonutChart({
<div className="flex items-center justify-center gap-6 w-full">
{node}
<svg width={128} height={128} viewBox="0 0 128 128" className="flex-shrink-0">
<circle cx={cx} cy={cy} r={r} fill="none" stroke="#1e1e2e" strokeWidth={stroke} />
<circle
cx={cx}
cy={cy}
r={r}
fill="none"
stroke="rgb(var(--border))"
strokeWidth={stroke}
/>
{segments.map(({ label, value, color }, i) => {
const dash = (value / total) * circumference;
const gap = circumference - dash;
@@ -1010,7 +1006,7 @@ function AnalyticsTab() {
raw={data ? totalTokens.toLocaleString() : undefined}
sub={data ? `${cacheHitPct}${t("cacheHitRate")}` : undefined}
icon={Cpu}
color="text-violet-400"
color="text-violet-700 dark:text-violet-400"
loading={!data}
/>
<StatPill
@@ -1032,7 +1028,7 @@ function AnalyticsTab() {
raw={data ? data.overview.total_events.toLocaleString() : undefined}
sub={data ? `~${data.avg_events_per_session}${t("perSession")}` : undefined}
icon={Zap}
color="text-yellow-400"
color="text-yellow-700 dark:text-yellow-400"
loading={!data}
/>
</div>
@@ -1155,7 +1151,9 @@ function AnalyticsTab() {
</div>
<div className="flex justify-between text-xs text-fg-muted">
<span>{t("cacheEfficiency")}</span>
<span className="text-violet-400 font-mono">{cacheHitPct}%</span>
<span className="text-violet-700 dark:text-violet-400 font-mono">
{cacheHitPct}%
</span>
</div>
</div>
</div>
@@ -1180,12 +1178,12 @@ function AnalyticsTab() {
{
label: t("common:token.cacheRead"),
value: data?.tokens.total_cache_read ?? 0,
color: "text-violet-400",
color: "text-violet-700 dark:text-violet-400",
},
{
label: t("common:token.cacheWrite"),
value: data?.tokens.total_cache_write ?? 0,
color: "text-yellow-400",
color: "text-yellow-700 dark:text-yellow-400",
},
{ label: t("common:total"), value: totalTokens, color: "text-fg-primary" },
].map(({ label, value, color }) => (
@@ -1331,7 +1329,7 @@ function AnalyticsTab() {
</div>
<div className="mt-4 pt-4 border-t border-border text-xs flex justify-between">
<span className="text-fg-muted">{t("common:total")}</span>
<span className="text-cyan-400 font-mono">
<span className="text-cyan-700 dark:text-cyan-400 font-mono">
<Tip raw={fmtCostFull(totalCost30d)}>{fmtCost(totalCost30d)}</Tip>
</span>
</div>
@@ -1761,7 +1759,14 @@ function SystemHealthTab() {
viewBox="0 0 96 96"
className="flex-shrink-0 cursor-default"
>
<circle cx="48" cy="48" r="38" fill="none" stroke="#1e1e2e" strokeWidth="14" />
<circle
cx="48"
cy="48"
r="38"
fill="none"
stroke="rgb(var(--border))"
strokeWidth="14"
/>
{(() => {
const r = 38,
cx = 48,
@@ -1882,7 +1887,14 @@ function SystemHealthTab() {
raw={`Score: ${healthScore.toFixed(1)} / 100\n\n• Success Rate (40%): ${successRate.toFixed(1)}%\n• Cache Hit (25%): ${cacheHitRate.toFixed(1)}%\n• Error Avoidance (25%): ${(100 - errorRate).toFixed(1)}%\n• Memory Health (10%): ${(100 - heapUsedPct).toFixed(1)}%`}
>
<svg width="120" height="120" viewBox="0 0 120 120" className="cursor-default">
<circle cx="60" cy="60" r="48" fill="none" stroke="#1e1e2e" strokeWidth="10" />
<circle
cx="60"
cy="60"
r="48"
fill="none"
stroke="rgb(var(--border))"
strokeWidth="10"
/>
<circle
cx="60"
cy="60"
@@ -1954,7 +1966,7 @@ function SystemHealthTab() {
>
<div className="text-center cursor-default">
<p className="text-[9px] text-fg-muted uppercase">Compact</p>
<p className="text-xs font-mono font-bold text-violet-400">
<p className="text-xs font-mono font-bold text-violet-700 dark:text-violet-400">
{workflow.compaction?.totalCompactions ?? 0}
</p>
</div>
@@ -2036,7 +2048,7 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<BarChart3 className="w-4 h-4 text-violet-400" />
<BarChart3 className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Concurrency</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">{lanes.length} intervals</span>
@@ -2058,7 +2070,7 @@ function SystemHealthTab() {
? "#fbbf24"
: lane.count > 0
? "#34d399"
: "#1e1e2e";
: "rgb(var(--border))";
return (
<div
key={i}
@@ -2172,7 +2184,7 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<GitBranch className="w-4 h-4 text-violet-400" />
<GitBranch className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">
Subagent Effectiveness
</span>
@@ -2282,7 +2294,7 @@ function SystemHealthTab() {
<div className="card p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Cpu className="w-4 h-4 text-cyan-400" />
<Cpu className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
<span className="text-xs text-fg-muted uppercase tracking-wider">Platform</span>
</div>
<span className="text-[10px] font-mono text-fg-muted">{info.server.node_version}</span>
@@ -2620,7 +2632,7 @@ export function Dashboard() {
label={t("activeSubagents")}
value={stats ? allSubagents.filter((a) => a.status === "working").length : ""}
icon={GitBranch}
accentColor="text-violet-400"
accentColor="text-violet-700 dark:text-violet-400"
trend={stats ? `${allSubagents.length}${t("totalTrend")}` : undefined}
loading={!stats}
/>
@@ -2629,7 +2641,7 @@ export function Dashboard() {
value={stats ? fmt(stats.events_today) : ""}
raw={stats ? stats.events_today.toLocaleString() : undefined}
icon={Zap}
accentColor="text-yellow-400"
accentColor="text-yellow-700 dark:text-yellow-400"
loading={!stats}
/>
<StatCard
@@ -2637,7 +2649,7 @@ export function Dashboard() {
value={stats ? fmt(stats.total_events) : ""}
raw={stats ? stats.total_events.toLocaleString() : undefined}
icon={Activity}
accentColor="text-violet-400"
accentColor="text-violet-700 dark:text-violet-400"
loading={!stats}
/>
<StatCard
@@ -2721,7 +2733,7 @@ export function Dashboard() {
empty rather than as a misalignment. */}
{!hasChildren && !isSubagent && (
<span
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-400/70"
className="w-6 h-6 flex-shrink-0 flex items-center justify-center text-violet-700/70 dark:text-violet-400/70"
aria-hidden="true"
title={t("common:noSubagents", "No subagents")}
>
@@ -2729,7 +2741,7 @@ export function Dashboard() {
</span>
)}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
@@ -2758,7 +2770,7 @@ export function Dashboard() {
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
{activeDesc > 0 && (
+18 -12
View File
@@ -664,23 +664,29 @@ export function SessionDetail() {
}`}
>
<ReasonIcon
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-300"}`}
className={`w-3.5 h-3.5 ${urgent ? "text-status-warning" : "text-yellow-700 dark:text-yellow-300"}`}
/>
</span>
<div className="flex-1 min-w-0">
<div
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-200"}`}
className={`text-sm font-medium ${urgent ? "text-status-warning" : "text-yellow-800 dark:text-yellow-200"}`}
>
{t("detail.waitingBanner.title")}
{cfg && (
<span className={urgent ? "text-status-warning/90" : "text-yellow-300/80"}>
<span
className={
urgent
? "text-status-warning/90"
: "text-yellow-700/80 dark:text-yellow-300/80"
}
>
{" · "}
{t(cfg.labelKey)}
</span>
)}
</div>
<div
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-400/60"}`}
className={`text-[11px] ${urgent ? "text-status-warning/70" : "text-yellow-700/60 dark:text-yellow-400/60"}`}
>
{cfg ? t(cfg.descKey) : t("detail.waitingBanner.generic")}
</div>
@@ -688,7 +694,7 @@ export function SessionDetail() {
{session.awaiting_input_since && (
<span
className={`text-[11px] flex-shrink-0 flex items-center gap-1.5 ${
urgent ? "text-status-warning/80" : "text-yellow-400/70"
urgent ? "text-status-warning/80" : "text-yellow-700/70 dark:text-yellow-400/70"
}`}
>
<span
@@ -736,7 +742,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "agents"
? "border-violet-500 text-violet-400"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -750,7 +756,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "conversation"
? "border-violet-500 text-violet-400"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -764,7 +770,7 @@ export function SessionDetail() {
}}
className={`flex items-center gap-2 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === "timeline"
? "border-violet-500 text-violet-400"
? "border-violet-500 text-violet-700 dark:text-violet-400"
: "border-transparent text-fg-muted hover:text-fg-secondary"
}`}
>
@@ -794,7 +800,7 @@ export function SessionDetail() {
{workflows.length > 0 && (
<div className="mb-4">
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Workflow className="w-3.5 h-3.5 text-violet-400" />
<Workflow className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
{wfT("runs.sessionTitle")}
<span className="text-fg-muted font-mono">· {workflows.length}</span>
</h3>
@@ -807,7 +813,7 @@ export function SessionDetail() {
) : (
<>
<h3 className="text-xs font-semibold text-fg-secondary uppercase tracking-wider mb-3 flex items-center gap-1.5">
<Bot className="w-3.5 h-3.5 text-violet-400" />
<Bot className="w-3.5 h-3.5 text-violet-700 dark:text-violet-400" />
{t("detail.agents")}
<span className="text-fg-muted font-mono">· {agents.length}</span>
</h3>
@@ -885,7 +891,7 @@ export function SessionDetail() {
)}
{isSubagent && !hasChildren && <span className="w-6 flex-shrink-0" />}
{isSubagent && (
<GitBranch className="w-3 h-3 text-violet-400 flex-shrink-0" />
<GitBranch className="w-3 h-3 text-violet-700 dark:text-violet-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<AgentCard
@@ -928,7 +934,7 @@ export function SessionDetail() {
onClick={() =>
setExpandedAgents((prev) => new Set([...prev, agent.id]))
}
className="ml-7 mt-1 text-[11px] text-violet-400 hover:text-violet-300 transition-colors"
className="ml-7 mt-1 text-[11px] text-violet-700 dark:text-violet-400 hover:text-violet-700 dark:hover:text-violet-300 transition-colors"
>
{t("common:subagent_label", { count: totalDesc })}
</button>
+1 -1
View File
@@ -436,7 +436,7 @@ export function Sessions() {
</p>
{session.source && session.source !== "local" && (
<span
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
className="inline-flex items-center gap-1 text-[10px] font-semibold text-sky-700 dark:text-sky-300 bg-sky-500/10 border border-sky-500/25 px-1.5 py-0.5 rounded-full"
title={t("remoteSourceBadgeTitle", "Collected from a remote machine")}
>
<Server className="w-2.5 h-2.5" />
+18 -14
View File
@@ -880,7 +880,9 @@ export function Settings() {
// just Sonnet 5), so a future model with a launch promo needs no code change.
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
<label className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-wider text-violet-300/70">{t(labelKey)}</span>
<span className="text-[10px] uppercase tracking-wider text-violet-700/70 dark:text-violet-300/70">
{t(labelKey)}
</span>
<input
type={opts.date ? "text" : "number"}
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
@@ -896,7 +898,7 @@ export function Settings() {
<td colSpan={10} className="px-4 pb-3 pt-2">
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
<div className="flex items-center gap-1.5 mb-2">
<span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider">
<span className="text-[11px] font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wider">
{t("pricing.introRatesTitle")}
</span>
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
@@ -1187,7 +1189,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary">
{rule.display_name}
{rule.intro_until && (
<div className="text-[11px] font-normal text-violet-400/80 mt-0.5">
<div className="text-[11px] font-normal text-violet-700/80 dark:text-violet-400/80 mt-0.5">
{t("pricing.introNote", {
input: rule.intro_input_per_mtok,
output: rule.intro_output_per_mtok,
@@ -1199,7 +1201,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.input_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_input_per_mtok}
</span>
)}
@@ -1207,7 +1209,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.output_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_output_per_mtok}
</span>
)}
@@ -1215,7 +1217,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_read_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_read_per_mtok}
</span>
)}
@@ -1223,7 +1225,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_per_mtok}
</span>
)}
@@ -1231,7 +1233,7 @@ export function Settings() {
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
${rule.cache_write_1h_per_mtok}
{rule.intro_until && (
<span className="block text-[11px] text-violet-400/80">
<span className="block text-[11px] text-violet-700/80 dark:text-violet-400/80">
${rule.intro_cache_write_1h_per_mtok}
</span>
)}
@@ -1580,7 +1582,7 @@ export function Settings() {
/>
</div>
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
<CheckCircle className="w-4 h-4 text-violet-400 flex-shrink-0" />
<CheckCircle className="w-4 h-4 text-violet-700 dark:text-violet-400 flex-shrink-0" />
<Toggle
checked={notifPrefs.onSessionComplete}
onChange={(v) => updateNotifPrefs({ onSessionComplete: v })}
@@ -1688,9 +1690,11 @@ export function Settings() {
const tableIcons: Record<string, React.ReactNode> = {
sessions: <Layers className="w-4 h-4 text-blue-500" />,
agents: <Users className="w-4 h-4 text-status-success" />,
events: <Activity className="w-4 h-4 text-violet-400" />,
events: <Activity className="w-4 h-4 text-violet-700 dark:text-violet-400" />,
token_usage: <Coins className="w-4 h-4 text-status-warning" />,
model_pricing: <BarChart3 className="w-4 h-4 text-cyan-400" />,
model_pricing: (
<BarChart3 className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
),
};
const tableLabels: Record<string, string> = {
sessions: t("tables.sessions"),
@@ -1725,7 +1729,7 @@ export function Settings() {
})()}
<div className="bg-surface-2 rounded-lg px-3 py-3 border-l-2 border-indigo-500/20">
<div className="flex items-center gap-2 mb-1.5">
<HardDrive className="w-4 h-4 text-indigo-400" />
<HardDrive className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("data.dbSize")}
</p>
@@ -1870,7 +1874,7 @@ export function Settings() {
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-indigo-400" />
<Server className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.release")}
</p>
@@ -1908,7 +1912,7 @@ export function Settings() {
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Globe className="w-4 h-4 text-violet-400" />
<Globe className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.platform")}
</p>
+1 -1
View File
@@ -224,7 +224,7 @@ export function Workflows() {
<div className="card p-4 space-y-3">
<div>
<h2 className="text-sm font-semibold text-fg-secondary flex items-center gap-2">
<Workflow className="w-4 h-4 text-violet-400" />
<Workflow className="w-4 h-4 text-violet-700 dark:text-violet-400" />
{t("runs.title")}
</h2>
<p className="text-xs text-fg-muted mt-0.5">{t("runs.subtitle")}</p>
@@ -0,0 +1,30 @@
/**
* @file Dashboard.heatmap.test.ts
* @description Pins the activity heatmap's colour ramp to the theme tokens: it
* runs from the page surface to the accent in every theme, instead of a fixed
* near-black indigo that vanished into dark and blotted out light themes.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect } from "vitest";
import { cellColor } from "../Dashboard";
describe("heatmap cellColor", () => {
it("paints an empty day as the bare surface", () => {
expect(cellColor(0, 10)).toBe("rgb(var(--surface-0))");
});
it("mixes the accent into the surface, more for busier days, and never a fixed rgb", () => {
const low = cellColor(1, 100);
const high = cellColor(100, 100);
for (const c of [low, high]) {
expect(c).toMatch(
/^color-mix\(in srgb, rgb\(var\(--accent\)\) \d+%, rgb\(var\(--surface-0\)\)\)$/
);
}
const pct = (c: string) => Number(/(\d+)%/.exec(c)?.[1]);
expect(pct(high)).toBe(100);
expect(pct(low)).toBeGreaterThan(0);
expect(pct(low)).toBeLessThan(pct(high));
});
});
File diff suppressed because it is too large Load Diff