feat: add CLI Apps settings MVP

This commit is contained in:
Xubin Ren
2026-05-23 00:33:31 +08:00
parent a5a956d9af
commit e2d00ffc8f
44 changed files with 4338 additions and 77 deletions
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import type { CliAppInfo } from "@/lib/types";
import { cn } from "@/lib/utils";
export type CliAppMentionSegment =
| { kind: "text"; text: string }
| { kind: "cli"; text: string; app: CliAppInfo };
export function cliAppInitials(app: CliAppInfo): string {
const value = app.display_name || app.name;
return (
value
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase()
);
}
export function splitCliAppMentionSegments(
value: string,
cliApps: CliAppInfo[],
): CliAppMentionSegment[] {
if (!value || cliApps.length === 0) return value ? [{ kind: "text", text: value }] : [];
const appsByName = new Map(
cliApps
.filter((app) => app.installed)
.map((app) => [app.name.toLowerCase(), app]),
);
if (appsByName.size === 0) return [{ kind: "text", text: value }];
const segments: CliAppMentionSegment[] = [];
const mentionRe = /(^|[\s([{])@([a-z0-9_-]+)\b/gi;
let cursor = 0;
let match: RegExpExecArray | null;
while ((match = mentionRe.exec(value)) !== null) {
const prefix = match[1] ?? "";
const name = match[2] ?? "";
const app = appsByName.get(name.toLowerCase());
if (!app) continue;
const mentionStart = match.index + prefix.length;
const mentionEnd = mentionStart + name.length + 1;
if (mentionStart > cursor) {
segments.push({ kind: "text", text: value.slice(cursor, mentionStart) });
}
segments.push({ kind: "cli", text: value.slice(mentionStart, mentionEnd), app });
cursor = mentionEnd;
}
if (cursor < value.length) {
segments.push({ kind: "text", text: value.slice(cursor) });
}
return segments.length ? segments : [{ kind: "text", text: value }];
}
export function CliAppMentionText({
text,
cliApps,
}: {
text: string;
cliApps: CliAppInfo[];
}) {
const segments = splitCliAppMentionSegments(text, cliApps);
if (!segments.some((segment) => segment.kind === "cli")) return <>{text}</>;
return (
<>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="message"
/>
);
})}
</>
);
}
export function CliAppMentionToken({
app,
label,
variant,
isHero = false,
}: {
app: CliAppInfo;
label: string;
variant: "composer" | "message";
isHero?: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
const mentionName = label.startsWith("@") ? label.slice(1) : label;
const showLogo = Boolean(app.logo_url) && !failed;
const testIdPrefix = variant === "composer" ? "composer" : "message";
return (
<span
data-testid={`${testIdPrefix}-cli-mention-${app.name}`}
className="relative inline transition-[color,text-shadow] duration-150"
style={{
color,
textShadow: `0 0 10px ${alphaColor(color, 24)}`,
}}
>
<span
className={cn("relative inline-block", showLogo && "text-transparent")}
style={{ lineHeight: "inherit" }}
>
@
{showLogo ? (
<span
data-testid={`${testIdPrefix}-cli-mention-logo-${app.name}`}
className={cn(
"absolute left-1/2 top-1/2 grid place-items-center overflow-hidden rounded-[3px]",
"-translate-x-1/2 -translate-y-1/2",
isHero ? "h-[0.74em] w-[0.74em]" : "h-[0.72em] w-[0.72em]",
)}
>
<img
src={app.logo_url ?? ""}
alt=""
className="h-full w-full object-contain"
onError={() => setFailed(true)}
/>
</span>
) : null}
</span>
{mentionName}
</span>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
+40 -2
View File
@@ -1,6 +1,7 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
@@ -8,16 +9,18 @@ import {
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CliAppMentionText } from "@/components/CliAppMentionText";
import { ImageLightbox } from "@/components/ImageLightbox";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import { cn } from "@/lib/utils";
import { formatTurnLatency } from "@/lib/format";
import type { UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
import type { CliAppInfo, UICliAppAttachment, UIImage, UIMediaAttachment, UIMessage } from "@/lib/types";
interface MessageBubbleProps {
message: UIMessage;
/** When false, hide the assistant reply copy button (mid-turn text before more agent activity). Default true. */
showAssistantCopyAction?: boolean;
cliApps?: CliAppInfo[];
}
/**
@@ -32,11 +35,16 @@ interface MessageBubbleProps {
export function MessageBubble({
message,
showAssistantCopyAction = true,
cliApps = [],
}: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300";
const mentionCliApps = useMemo(
() => mergeCliMentionApps(cliApps, message.cliApps),
[cliApps, message.cliApps],
);
useEffect(() => {
return () => {
@@ -88,7 +96,7 @@ export function MessageBubble({
"text-left text-[16px]/[1.75] whitespace-pre-wrap break-words",
)}
>
{message.content}
<CliAppMentionText text={message.content} cliApps={mentionCliApps} />
</p>
) : null}
</div>
@@ -158,6 +166,36 @@ export function MessageBubble({
);
}
function mergeCliMentionApps(
cliApps: CliAppInfo[],
attachments: UICliAppAttachment[] | undefined,
): CliAppInfo[] {
if (!attachments?.length) return cliApps;
const byName = new Map(cliApps.map((app) => [app.name.toLowerCase(), app]));
for (const attachment of attachments) {
const name = attachment.name?.trim();
if (!name) continue;
const existing = byName.get(name.toLowerCase());
byName.set(name.toLowerCase(), {
name,
display_name: attachment.display_name || existing?.display_name || name,
category: attachment.category || existing?.category || "cli",
description: existing?.description || "",
requires: existing?.requires || "",
source: existing?.source || "attached",
entry_point: attachment.entry_point || existing?.entry_point || "",
install_supported: existing?.install_supported ?? true,
installed: true,
available: existing?.available ?? true,
status: existing?.status || "installed",
logo_url: attachment.logo_url ?? existing?.logo_url ?? null,
brand_color: attachment.brand_color ?? existing?.brand_color ?? null,
skill_installed: existing?.skill_installed ?? true,
});
}
return Array.from(byName.values());
}
function MessageMedia({
media,
align,
+583 -2
View File
@@ -32,6 +32,8 @@ import {
Loader2,
LogOut,
Moon,
Package,
PlayCircle,
Orbit,
Palette,
Pencil,
@@ -41,6 +43,7 @@ import {
ShieldCheck,
SlidersHorizontal,
Sparkles,
Trash2,
Triangle,
Waves,
Zap,
@@ -59,6 +62,8 @@ import {
import { Input } from "@/components/ui/input";
import {
fetchSettings,
fetchCliApps,
runCliAppAction,
updateImageGenerationSettings,
updateProviderSettings,
updateSettings,
@@ -67,6 +72,8 @@ import {
import { cn } from "@/lib/utils";
import { useClient } from "@/providers/ClientProvider";
import type {
CliAppInfo,
CliAppsPayload,
ImageGenerationSettingsUpdate,
SettingsPayload,
WebSearchSettingsUpdate,
@@ -79,6 +86,7 @@ type SettingsSectionKey =
| "providers"
| "image"
| "web"
| "cliApps"
| "runtime"
| "advanced";
@@ -89,6 +97,7 @@ interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft {
@@ -110,6 +119,7 @@ const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: true,
};
const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
@@ -146,6 +156,7 @@ function readLocalPreferences(): LocalPreferences {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos !== false,
};
} catch {
return DEFAULT_LOCAL_PREFS;
@@ -177,8 +188,11 @@ export function SettingsView({
const { t } = useTranslation();
const { token } = useClient();
const [settings, setSettings] = useState<SettingsPayload | null>(null);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [loading, setLoading] = useState(true);
const [cliAppsLoading, setCliAppsLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [cliAppsAction, setCliAppsAction] = useState<string | null>(null);
const [providerSaving, setProviderSaving] = useState<string | null>(null);
const [webSearchSaving, setWebSearchSaving] = useState(false);
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
@@ -186,6 +200,12 @@ export function SettingsView({
const [activeSection, setActiveSection] = useState<SettingsSectionKey>("overview");
const [expandedProvider, setExpandedProvider] = useState<string | null>(null);
const [providerQuery, setProviderQuery] = useState("");
const [cliAppsQuery, setCliAppsQuery] = useState("");
const [cliAppsCategory, setCliAppsCategory] = useState("all");
const [cliAppsInstallFilter, setCliAppsInstallFilter] = useState<"all" | "installed" | "notInstalled">("all");
const [cliAppsMessage, setCliAppsMessage] = useState<string | null>(null);
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [providerForms, setProviderForms] = useState<Record<string, { apiKey: string; apiBase: string }>>({});
const [visibleProviderKeys, setVisibleProviderKeys] = useState<Record<string, boolean>>({});
const [editingProviderKeys, setEditingProviderKeys] = useState<Record<string, boolean>>({});
@@ -285,6 +305,27 @@ export function SettingsView({
};
}, [applyPayload, token]);
useEffect(() => {
let cancelled = false;
setCliAppsLoading(true);
fetchCliApps(token)
.then((payload) => {
if (!cancelled) {
setCliApps(payload);
setCliAppsError(null);
}
})
.catch((err) => {
if (!cancelled) setCliAppsError((err as Error).message);
})
.finally(() => {
if (!cancelled) setCliAppsLoading(false);
});
return () => {
cancelled = true;
};
}, [token]);
useEffect(() => {
try {
window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(localPrefs));
@@ -574,6 +615,26 @@ export function SettingsView({
});
};
const handleCliAppAction = async (
action: "install" | "update" | "uninstall" | "test",
name: string,
) => {
const key = `${action}:${name}`;
setCliAppsAction(key);
setCliAppsMessage(null);
setCliAppsError(null);
try {
const payload = await runCliAppAction(token, action, name);
setCliApps(payload);
setCliAppsMessage(payload.last_action?.message ?? null);
setCliAppsFocusName(action === "uninstall" ? null : name);
} catch (err) {
setCliAppsError((err as Error).message);
} finally {
setCliAppsAction(null);
}
};
const renderSection = () => {
if (!settings) return null;
switch (activeSection) {
@@ -618,6 +679,7 @@ export function SettingsView({
editingProviderKeys={editingProviderKeys}
providerSaving={providerSaving}
query={providerQuery}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setProviderQuery}
onToggleProvider={handleToggleProvider}
onToggleProviderKey={toggleProviderKeyVisibility}
@@ -677,6 +739,26 @@ export function SettingsView({
requiresRestartPending={pendingRestartSections.web}
/>
);
case "cliApps":
return (
<CliAppsSettings
payload={cliApps}
loading={cliAppsLoading}
query={cliAppsQuery}
category={cliAppsCategory}
installFilter={cliAppsInstallFilter}
actionKey={cliAppsAction}
message={cliAppsMessage}
error={cliAppsError}
focusName={cliAppsFocusName}
showBrandLogos={localPrefs.brandLogos}
onQueryChange={setCliAppsQuery}
onCategoryChange={setCliAppsCategory}
onInstallFilterChange={setCliAppsInstallFilter}
onAction={handleCliAppAction}
onBackToChat={onBackToChat}
/>
);
case "runtime":
return (
<RuntimeSettings
@@ -752,6 +834,7 @@ const SETTINGS_NAV_ITEMS: Array<{ key: SettingsSectionKey; icon: LucideIcon; fal
{ key: "providers", icon: KeyRound, fallback: "Providers" },
{ key: "image", icon: ImageIcon, fallback: "Image" },
{ key: "web", icon: Globe2, fallback: "Web" },
{ key: "cliApps", icon: Package, fallback: "CLI Apps" },
{ key: "runtime", icon: Server, fallback: "Runtime" },
{ key: "advanced", icon: ShieldCheck, fallback: "Advanced" },
];
@@ -1077,6 +1160,16 @@ function AppearanceSettings({
label={localPrefs.codeWrap ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx("settings.help.brandLogos", "Show third-party provider and CLI logos in Settings.")}
>
<ToggleButton
checked={localPrefs.brandLogos}
onChange={(brandLogos) => onChangeLocalPrefs((prev) => ({ ...prev, brandLogos }))}
label={localPrefs.brandLogos ? tx("settings.values.on", "On") : tx("settings.values.off", "Off")}
/>
</SettingsRow>
</SettingsGroup>
</section>
</div>
@@ -1211,6 +1304,7 @@ function ProvidersSettings({
editingProviderKeys,
providerSaving,
query,
showBrandLogos,
onQueryChange,
onToggleProvider,
onToggleProviderKey,
@@ -1229,6 +1323,7 @@ function ProvidersSettings({
editingProviderKeys: Record<string, boolean>;
providerSaving: string | null;
query: string;
showBrandLogos: boolean;
onQueryChange: (query: string) => void;
onToggleProvider: (provider: string) => void;
onToggleProviderKey: (provider: string) => void;
@@ -1272,7 +1367,10 @@ function ProvidersSettings({
className="flex min-h-[70px] w-full items-center justify-between gap-4 px-4 py-3 text-left transition-colors hover:bg-muted/35 sm:px-5"
>
<span className="flex min-w-0 items-center gap-3">
<ProviderIcon provider={provider.name} />
<ProviderIcon
provider={provider.name}
showBrandLogos={showBrandLogos}
/>
<span className="min-w-0">
<span className="block truncate text-[15px] font-semibold leading-5 text-foreground">
{provider.label}
@@ -1437,6 +1535,7 @@ function ProvidersSettings({
>
{filteredUnconfigured.map(renderProviderRow)}
</ProviderSection>
<ThirdPartyBrandNotice />
</div>
);
}
@@ -1831,6 +1930,383 @@ function WebSettings({
);
}
function CliAppsSettings({
payload,
loading,
query,
category,
installFilter,
actionKey,
message,
error,
focusName,
showBrandLogos,
onQueryChange,
onCategoryChange,
onInstallFilterChange,
onAction,
onBackToChat,
}: {
payload: CliAppsPayload | null;
loading: boolean;
query: string;
category: string;
installFilter: "all" | "installed" | "notInstalled";
actionKey: string | null;
message: string | null;
error: string | null;
focusName: string | null;
showBrandLogos: boolean;
onQueryChange: (value: string) => void;
onCategoryChange: (value: string) => void;
onInstallFilterChange: (value: "all" | "installed" | "notInstalled") => void;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const apps = payload?.apps ?? [];
const categories = useMemo(
() => ["all", ...Array.from(new Set(apps.map((app) => app.category))).sort()],
[apps],
);
const normalizedQuery = query.trim().toLowerCase();
const filteredApps = apps.filter((app) => {
const categoryMatch = category === "all" || app.category === category;
if (!categoryMatch) return false;
if (installFilter === "installed" && !app.installed) return false;
if (installFilter === "notInstalled" && app.installed) return false;
if (!normalizedQuery) return true;
return (
app.display_name.toLowerCase().includes(normalizedQuery) ||
app.name.toLowerCase().includes(normalizedQuery) ||
app.description.toLowerCase().includes(normalizedQuery) ||
app.category.toLowerCase().includes(normalizedQuery)
);
});
const categoryLabel =
category === "all"
? tx("settings.cliApps.allCategories", "All categories")
: category;
const installFilterOptions = [
{ value: "all", label: tx("settings.cliApps.filterAll", "All") },
{ value: "installed", label: tx("settings.cliApps.filterInstalled", "Installed CLIs") },
{ value: "notInstalled", label: tx("settings.cliApps.filterNotInstalled", "Not installed") },
];
const focusedApp = focusName
? apps.find((app) => app.name === focusName && app.installed)
: null;
const visibleStatusMessage = error || (!focusedApp ? message : null);
return (
<div className="space-y-5">
<section className="space-y-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<SettingsSectionTitle>{tx("settings.sections.cliApps", "CLI Apps")}</SettingsSectionTitle>
<p className="mt-1 text-[13px] text-muted-foreground">
{tx("settings.cliApps.summary", "{{installed}} of {{total}} CLIs installed")
.replace("{{installed}}", String(payload?.installed_count ?? 0))
.replace("{{total}}", String(apps.length))}
</p>
</div>
<SegmentedControl
value={installFilter}
options={installFilterOptions}
onChange={(value) => onInstallFilterChange(value as "all" | "installed" | "notInstalled")}
/>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={tx("settings.cliApps.searchPlaceholder", "Search CLIs")}
className="h-10 w-full rounded-full border-border/65 bg-card/80 pl-9 text-[13px] shadow-sm sm:max-w-[320px]"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-10 justify-between rounded-full bg-card/80 px-4">
<span className="max-w-[180px] truncate">{categoryLabel}</span>
<ChevronDown className="ml-2 h-3.5 w-3.5" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-[320px] overflow-y-auto">
{categories.map((item) => (
<DropdownMenuItem key={item} onClick={() => onCategoryChange(item)}>
{item === "all" ? tx("settings.cliApps.allCategories", "All categories") : item}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</section>
{visibleStatusMessage ? (
<div
className={cn(
"rounded-[10px] border px-3.5 py-2.5 text-[12.5px]",
error
? "border-destructive/20 bg-destructive/5 text-destructive"
: "border-border/55 bg-muted/35 text-muted-foreground",
)}
>
{visibleStatusMessage}
</div>
) : null}
{focusedApp ? (
<CliAppReadyPanel
app={focusedApp}
showBrandLogos={showBrandLogos}
onBackToChat={onBackToChat}
/>
) : null}
{loading ? (
<div className="flex h-36 items-center justify-center rounded-[8px] border border-border/45 bg-card/82 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.cliApps.loading", "Loading CLI Apps...")}
</div>
) : (
<section>
<div className="grid gap-2">
{filteredApps.map((app) => (
<CliAppCard
key={app.name}
app={app}
actionKey={actionKey}
showBrandLogos={showBrandLogos}
onAction={onAction}
/>
))}
</div>
{!filteredApps.length ? (
<div className="rounded-[8px] border border-border/45 bg-card/82 px-4 py-8 text-center text-sm text-muted-foreground">
{tx("settings.cliApps.empty", "No CLI Apps match this filter.")}
</div>
) : null}
</section>
)}
<ThirdPartyBrandNotice />
</div>
);
}
function CliAppReadyPanel({
app,
showBrandLogos,
onBackToChat,
}: {
app: CliAppInfo;
showBrandLogos: boolean;
onBackToChat: () => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const prompt = t("settings.cliApps.readyPrompt", {
name: app.name,
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
});
const copyPrompt = () => {
if (!navigator.clipboard) return;
void navigator.clipboard.writeText(prompt).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 1400);
});
};
return (
<section
className={cn(
"rounded-[12px] border border-border/55 bg-card/88 px-4 py-3",
"shadow-[0_8px_26px_rgba(15,23,42,0.055)]",
)}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
</span>
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
<span className="font-mono">@{app.name}</span>
<span aria-hidden>·</span>
<span className="truncate font-mono">{app.entry_point || app.name}</span>
<span aria-hidden>·</span>
<span>{app.category}</span>
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="ghost"
onClick={copyPrompt}
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
>
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
{copied
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
</Button>
<Button
type="button"
size="sm"
onClick={onBackToChat}
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
</Button>
</div>
</div>
</section>
);
}
function CliAppCard({
app,
actionKey,
showBrandLogos,
onAction,
}: {
app: CliAppInfo;
actionKey: string | null;
showBrandLogos: boolean;
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const installBusy = actionKey === `install:${app.name}`;
const updateBusy = actionKey === `update:${app.name}`;
const uninstallBusy = actionKey === `uninstall:${app.name}`;
const testBusy = actionKey === `test:${app.name}`;
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
return (
<article className="flex min-w-0 items-center gap-3 rounded-[8px] border border-border/45 bg-card/82 px-4 py-3 shadow-[0_6px_22px_rgba(15,23,42,0.045)]">
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
{app.display_name}
</h3>
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
{app.category}
</span>
</div>
<div className="mt-0.5 truncate text-[12px] text-muted-foreground">
{app.entry_point || app.name}
</div>
<p className="mt-1 truncate text-[12px] leading-5 text-muted-foreground">
{app.requires
? `${tx("settings.cliApps.requires", "Requires")}: ${app.requires}`
: app.description || tx("settings.cliApps.noDescription", "No description available.")}
</p>
</div>
<div className="shrink-0">
{app.installed ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
className="h-8 rounded-full border-emerald-500/20 bg-emerald-500/10 px-3 text-[12px] font-semibold text-emerald-700 hover:bg-emerald-500/12 dark:text-emerald-300"
>
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
{tx("settings.cliApps.statusInstalled", "CLI installed")}
<ChevronDown className="ml-1.5 h-3 w-3" aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
<PlayCircle className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.test", "Test CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
<RotateCcw className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.update", "Update CLI")}
</DropdownMenuItem>
<DropdownMenuItem disabled={busy} onClick={() => onAction("uninstall", app.name)}>
<Trash2 className="mr-2 h-3.5 w-3.5" aria-hidden />
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : app.install_supported ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() => onAction("install", app.name)}
className="h-8 rounded-full px-4 text-[12px] font-semibold"
>
{installBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : null}
{tx("settings.cliApps.install", "Install CLI")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
disabled
className="h-8 rounded-full px-3 text-[12px] font-semibold"
>
{tx("settings.cliApps.unavailable", "Unavailable")}
</Button>
)}
</div>
</article>
);
}
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
const [failed, setFailed] = useState(false);
const bg = app.brand_color || "hsl(var(--muted))";
const initials = app.display_name
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase())
.join("") || app.name.slice(0, 2).toUpperCase();
if (showBrandLogos && app.logo_url && !failed) {
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] border border-border/45 bg-background"
style={{ boxShadow: `inset 0 0 0 1px ${app.brand_color ?? "transparent"}22` }}
>
<img
src={app.logo_url}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="grid h-11 w-11 shrink-0 place-items-center rounded-[8px] text-[13px] font-semibold text-white"
style={{ backgroundColor: bg }}
>
{initials}
</span>
);
}
function RuntimeSettings({
form,
setForm,
@@ -2079,6 +2555,18 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
function ThirdPartyBrandNotice() {
const { t } = useTranslation();
return (
<p className="px-1 text-[11.5px] leading-5 text-muted-foreground/75">
{t("settings.legal.thirdPartyBrands", {
defaultValue:
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
})}
</p>
);
}
function orderUnconfiguredProviders(
providers: SettingsPayload["providers"],
): SettingsPayload["providers"] {
@@ -2126,6 +2614,63 @@ function providerLabel(
return providers.find((provider) => provider.name === value)?.label ?? value;
}
interface ProviderBrand {
logoUrl: string;
color: string;
initials: string;
}
function faviconUrl(domain: string): string {
return `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
}
const PROVIDER_BRAND_ALIASES: Record<string, string> = {
byteplus_coding_plan: "byteplus",
minimax_anthropic: "minimax",
openai_codex: "openai",
volcengine_coding_plan: "volcengine",
};
const PROVIDER_BRANDS: Record<string, ProviderBrand> = {
aihubmix: { logoUrl: faviconUrl("aihubmix.com"), color: "#111827", initials: "AH" },
ant_ling: { logoUrl: faviconUrl("ant-ling.com"), color: "#7C3AED", initials: "AL" },
anthropic: { logoUrl: faviconUrl("anthropic.com"), color: "#D97757", initials: "A" },
atomic_chat: { logoUrl: faviconUrl("atomic.chat"), color: "#111827", initials: "AC" },
azure_openai: { logoUrl: faviconUrl("azure.microsoft.com"), color: "#0078D4", initials: "AZ" },
bedrock: { logoUrl: faviconUrl("aws.amazon.com"), color: "#FF9900", initials: "AWS" },
byteplus: { logoUrl: faviconUrl("byteplus.com"), color: "#325CFF", initials: "BP" },
dashscope: { logoUrl: faviconUrl("dashscope.aliyun.com"), color: "#FF6A00", initials: "DS" },
deepseek: { logoUrl: faviconUrl("deepseek.com"), color: "#4D6BFE", initials: "DS" },
gemini: { logoUrl: faviconUrl("gemini.google.com"), color: "#4285F4", initials: "G" },
github_copilot: { logoUrl: faviconUrl("github.com"), color: "#24292F", initials: "GH" },
groq: { logoUrl: faviconUrl("groq.com"), color: "#F55036", initials: "GQ" },
huggingface: { logoUrl: faviconUrl("huggingface.co"), color: "#FF9D00", initials: "HF" },
lm_studio: { logoUrl: faviconUrl("lmstudio.ai"), color: "#111827", initials: "LM" },
longcat: { logoUrl: faviconUrl("longcat.chat"), color: "#111827", initials: "LC" },
minimax: { logoUrl: faviconUrl("minimax.io"), color: "#111827", initials: "MM" },
mistral: { logoUrl: faviconUrl("mistral.ai"), color: "#FA520F", initials: "M" },
moonshot: { logoUrl: faviconUrl("moonshot.ai"), color: "#111827", initials: "MS" },
novita: { logoUrl: faviconUrl("novita.ai"), color: "#7C3AED", initials: "N" },
nvidia: { logoUrl: faviconUrl("nvidia.com"), color: "#76B900", initials: "NV" },
ollama: { logoUrl: faviconUrl("ollama.com"), color: "#111827", initials: "O" },
openai: { logoUrl: faviconUrl("openai.com"), color: "#111827", initials: "AI" },
openrouter: { logoUrl: faviconUrl("openrouter.ai"), color: "#111827", initials: "OR" },
ovms: { logoUrl: faviconUrl("openvino.ai"), color: "#0071C5", initials: "OV" },
qianfan: { logoUrl: faviconUrl("cloud.baidu.com"), color: "#2932E1", initials: "QF" },
siliconflow: { logoUrl: faviconUrl("siliconflow.cn"), color: "#111827", initials: "SF" },
skywork: { logoUrl: faviconUrl("skywork.ai"), color: "#5B5BF6", initials: "SW" },
stepfun: { logoUrl: faviconUrl("stepfun.com"), color: "#2F6BFF", initials: "SF" },
volcengine: { logoUrl: faviconUrl("volcengine.com"), color: "#1664FF", initials: "VE" },
vllm: { logoUrl: faviconUrl("vllm.ai"), color: "#2563EB", initials: "VL" },
xiaomi_mimo: { logoUrl: faviconUrl("xiaomimimo.com"), color: "#FF6900", initials: "MI" },
zhipu: { logoUrl: faviconUrl("bigmodel.cn"), color: "#155EEF", initials: "Z" },
};
function providerBrand(provider: string): ProviderBrand | null {
const key = PROVIDER_BRAND_ALIASES[provider] ?? provider;
return PROVIDER_BRANDS[key] ?? null;
}
const PROVIDER_ICONS: Record<string, LucideIcon> = {
custom: Hexagon,
openrouter: Sparkles,
@@ -2160,8 +2705,44 @@ const PROVIDER_ICONS: Record<string, LucideIcon> = {
nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {
function ProviderIcon({
provider,
showBrandLogos,
}: {
provider: string;
showBrandLogos: boolean;
}) {
const [failed, setFailed] = useState(false);
const brand = providerBrand(provider);
const Icon = PROVIDER_ICONS[provider] ?? Hexagon;
if (showBrandLogos && brand?.logoUrl && !failed) {
return (
<span
data-testid={`provider-logo-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center overflow-hidden rounded-[14px] border border-border/45 bg-background shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)]"
style={{ boxShadow: `inset 0 0 0 1px ${brand.color}22` }}
>
<img
src={brand.logoUrl}
alt=""
className="h-6 w-6 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
if (showBrandLogos && brand) {
return (
<span
data-testid={`provider-logo-fallback-${provider}`}
className="grid h-10 w-10 shrink-0 place-items-center rounded-[14px] text-[11px] font-semibold text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)]"
style={{ backgroundColor: brand.color }}
aria-hidden
>
{brand.initials}
</span>
);
}
return (
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-2xl bg-muted text-foreground/82 shadow-[inset_0_0_0_1px_rgba(0,0,0,0.025)] dark:bg-muted/70">
<Icon className="h-5 w-5" strokeWidth={2} aria-hidden />
@@ -1,11 +1,12 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, ChevronRight, Layers } from "lucide-react";
import { AlertCircle, ChevronRight, Layers, Terminal } from "lucide-react";
import { useTranslation } from "react-i18next";
import { cliAppInitials } from "@/components/CliAppMentionText";
import { FileReferenceChip } from "@/components/FileReferenceChip";
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
import type { UIFileEdit, UIMessage } from "@/lib/types";
import type { CliAppInfo, ToolProgressEvent, UIFileEdit, UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
@@ -24,6 +25,7 @@ export function isAgentActivityMember(m: UIMessage): boolean {
interface ActivityCounts {
reasoningSteps: number;
toolCalls: number;
cliCount: number;
fileCount: number;
added: number;
deleted: number;
@@ -32,6 +34,8 @@ interface ActivityCounts {
hasFailedFiles: boolean;
primaryFilePath?: string;
primaryFileTooltipPath?: string;
primaryCliName?: string;
primaryCliStatus?: CliRunStatus;
}
interface FileEditSummary {
@@ -47,17 +51,41 @@ interface FileEditSummary {
error?: string;
}
function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
interface CliRunSummary {
key: string;
name: string;
args: string[];
json: boolean;
workingDir?: string;
status: CliRunStatus;
error?: string;
}
type CliRunStatus = "running" | "done" | "error";
function countActivity(
messages: UIMessage[],
fileEdits: FileEditSummary[],
cliRuns: CliRunSummary[],
): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
const cliCount = cliRuns.length;
const primaryCli = cliRuns[cliRuns.length - 1];
const primaryCliName = primaryCli?.name;
const primaryCliStatus = primaryCli?.status;
for (const m of messages) {
if (isReasoningOnlyAssistant(m)) {
reasoningSteps += 1;
continue;
}
if (m.kind === "trace") {
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
toolCalls += lines;
const lines = traceLines(m);
for (const line of lines) {
if (!isCliRunTraceLine(line)) {
toolCalls += 1;
}
}
}
}
let added = 0;
@@ -89,6 +117,7 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
return {
reasoningSteps,
toolCalls,
cliCount,
fileCount: fileEdits.length,
added,
deleted,
@@ -97,6 +126,8 @@ function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): Act
hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
primaryFilePath,
primaryFileTooltipPath,
primaryCliName,
primaryCliStatus,
};
}
@@ -105,6 +136,7 @@ interface AgentActivityClusterProps {
/** True while the session turn is still running (drives “Working…” copy + header sheen). */
isTurnStreaming: boolean;
hasBodyBelow: boolean;
cliApps?: CliAppInfo[];
}
/**
@@ -115,15 +147,22 @@ export function AgentActivityCluster({
messages,
isTurnStreaming,
hasBodyBelow,
cliApps = [],
}: AgentActivityClusterProps) {
const { t } = useTranslation();
const fileEdits = useMemo(
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
[messages, isTurnStreaming],
);
const cliRuns = useMemo(() => collectCliRuns(messages), [messages]);
const cliAppsByName = useMemo(
() => new Map(cliApps.map((app) => [app.name.toLowerCase(), app])),
[cliApps],
);
const {
reasoningSteps,
toolCalls,
cliCount,
fileCount,
added,
deleted,
@@ -132,7 +171,9 @@ export function AgentActivityCluster({
hasFailedFiles,
primaryFilePath,
primaryFileTooltipPath,
} = countActivity(messages, fileEdits);
primaryCliName,
primaryCliStatus,
} = countActivity(messages, fileEdits, cliRuns);
const hasPendingFileEdit = fileEdits.some((edit) => edit.pending);
const [userToggledOuter, setUserToggledOuter] = useState(false);
@@ -148,7 +189,7 @@ export function AgentActivityCluster({
const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
const singleFilePath = fileCount === 1 ? primaryFilePath : undefined;
const singleFileTooltipPath = fileCount === 1 ? primaryFileTooltipPath : undefined;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || fileCount > 0;
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || fileCount > 0;
const fileActivitySummary = fileCount > 0
? hasPendingFileEdit && !singleFilePath
@@ -164,8 +205,22 @@ export function AgentActivityCluster({
})
: "";
const cliActivitySummary = cliCount > 0
? cliCount === 1 && primaryCliName
? t(cliActivitySummaryKey(primaryCliStatus, isTurnStreaming), {
name: primaryCliName,
defaultValue: cliActivitySummaryDefault(primaryCliStatus, isTurnStreaming),
})
: t(cliActivityManySummaryKey(cliRuns, isTurnStreaming), {
count: cliCount,
defaultValue: cliActivityManySummaryDefault(cliRuns, isTurnStreaming),
})
: "";
const summary = fileCount > 0
? fileActivitySummary
: cliCount > 0
? cliActivitySummary
: isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
@@ -254,6 +309,8 @@ export function AgentActivityCluster({
if (!hasVisibleActivity) return null;
const HeaderIcon = cliCount > 0 && fileCount === 0 && toolCalls === 0 ? Terminal : Layers;
return (
<div className={cn("w-full", hasBodyBelow && "mb-2")}>
<button
@@ -266,7 +323,7 @@ export function AgentActivityCluster({
aria-expanded={outerExpanded}
aria-label={summary}
>
<Layers className="h-3.5 w-3.5 shrink-0" aria-hidden />
<HeaderIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-left">
{singleFilePath ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
@@ -337,15 +394,25 @@ export function AgentActivityCluster({
);
}
if (m.kind === "trace") {
const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
return hasTraceLines ? (
const normalLines = traceLines(m).filter((line) => !parseCliRunTrace(line));
return normalLines.length > 0 ? (
<div key={m.id} className="flex flex-col gap-1">
<TraceGroup message={m} animClass="" />
<TraceGroup
message={{
...m,
traces: normalLines,
content: normalLines[normalLines.length - 1],
}}
animClass=""
/>
</div>
) : null;
}
return null;
})}
{cliRuns.length ? (
<CliRunGroup runs={cliRuns} active={isTurnStreaming} cliAppsByName={cliAppsByName} />
) : null}
{fileEdits.length ? <FileEditGroup edits={fileEdits} /> : null}
</div>
</div>
@@ -359,6 +426,181 @@ function shortFileName(path: string): string {
return path.split(/[\\/]/).pop() || path;
}
function traceLines(message: UIMessage): string[] {
if (message.traces?.length) return message.traces;
return message.content.trim() ? [message.content] : [];
}
const CLI_RUN_TOOL_NAMES = new Set(["run_cli_app", "cli_anything_run"]);
const CLI_RUN_STATUS_RANK: Record<CliRunStatus, number> = { running: 1, done: 2, error: 3 };
function isCliRunTraceLine(line: string): boolean {
return /^(run_cli_app|cli_anything_run)\(/.test(line.trim());
}
function parseCliRunTrace(line: string, status: CliRunStatus = "running"): CliRunSummary | null {
const match = /^(run_cli_app|cli_anything_run)\((.*)\)$/.exec(line.trim());
if (!match) return null;
const argsText = match[2].trim();
let argsObject: unknown = {};
if (argsText) {
try {
argsObject = JSON.parse(argsText);
} catch {
return {
key: line,
name: "cli",
args: [argsText],
json: false,
status,
};
}
}
return cliRunFromArguments(argsObject, { key: line, status });
}
function parseToolEventArguments(event: ToolProgressEvent): unknown {
const fnArgs = (event as { function?: { arguments?: unknown } }).function?.arguments;
const raw = fnArgs ?? event.arguments;
if (typeof raw !== "string") return raw ?? {};
if (!raw.trim()) return {};
try {
return JSON.parse(raw);
} catch {
return { args: [raw] };
}
}
function cliRunStatusFromPhase(phase: unknown): CliRunStatus {
if (phase === "error") return "error";
if (phase === "end") return "done";
return "running";
}
function cliRunError(event: ToolProgressEvent): string | undefined {
const error = event.error;
if (typeof error === "string") return error;
if (error && typeof error === "object") return JSON.stringify(error);
return undefined;
}
function cliRunFromArguments(
argsObject: unknown,
options: { key: string; status: CliRunStatus; error?: string },
): CliRunSummary {
if (!argsObject || typeof argsObject !== "object" || Array.isArray(argsObject)) {
return {
key: options.key,
name: "cli",
args: [],
json: false,
status: options.status,
error: options.error,
};
}
const record = argsObject as Record<string, unknown>;
const appName = typeof record.name === "string" && record.name.trim()
? record.name.trim()
: "cli";
const rawArgs = Array.isArray(record.args) ? record.args : [];
const cliArgs = rawArgs.filter((item): item is string => typeof item === "string");
return {
key: options.key,
name: appName,
args: cliArgs,
json: record.json === true || record.json === "true",
workingDir: typeof record.working_dir === "string" ? record.working_dir : undefined,
status: options.status,
error: options.error,
};
}
function cliRunFromEvent(event: ToolProgressEvent): CliRunSummary | null {
const name =
typeof (event as { function?: { name?: unknown } }).function?.name === "string"
? String((event as { function?: { name?: unknown } }).function?.name)
: typeof event.name === "string"
? event.name
: "";
if (!CLI_RUN_TOOL_NAMES.has(name)) return null;
const argsObject = parseToolEventArguments(event);
const key = event.call_id ? `call:${event.call_id}` : `${name}:${JSON.stringify(argsObject)}`;
return cliRunFromArguments(argsObject, {
key,
status: cliRunStatusFromPhase(event.phase),
error: cliRunError(event),
});
}
function mergeCliRun(existing: CliRunSummary | undefined, incoming: CliRunSummary): CliRunSummary {
if (!existing) return incoming;
return CLI_RUN_STATUS_RANK[incoming.status] >= CLI_RUN_STATUS_RANK[existing.status]
? { ...existing, ...incoming }
: existing;
}
function collectCliRuns(messages: UIMessage[]): CliRunSummary[] {
const runsByKey = new Map<string, CliRunSummary>();
for (const message of messages) {
if (message.kind !== "trace") continue;
let hasStructuredCliRun = false;
for (const event of message.toolEvents ?? []) {
const run = cliRunFromEvent(event);
if (!run) continue;
hasStructuredCliRun = true;
runsByKey.set(run.key, mergeCliRun(runsByKey.get(run.key), run));
}
if (hasStructuredCliRun) continue;
for (const line of traceLines(message)) {
const run = parseCliRunTrace(line);
if (!run || runsByKey.has(run.key)) continue;
runsByKey.set(run.key, run);
}
}
return [...runsByKey.values()];
}
function displayCliArg(arg: string): string {
return /\s/.test(arg) ? JSON.stringify(arg) : arg;
}
function formatCliArgs(run: CliRunSummary): string {
const args = [...(run.json ? ["--json"] : []), ...run.args].map(displayCliArg);
return args.join(" ");
}
function cliActivitySummaryKey(status: CliRunStatus | undefined, active: boolean): string {
if (status === "error") return "message.cliActivityFailedOne";
return active && status === "running" ? "message.cliActivityRunningOne" : "message.cliActivityRanOne";
}
function cliActivitySummaryDefault(status: CliRunStatus | undefined, active: boolean): string {
if (status === "error") return "CLI failed @{{name}}";
return `${active && status === "running" ? "Running" : "Ran"} CLI @{{name}}`;
}
function cliActivityManySummaryKey(runs: CliRunSummary[], active: boolean): string {
if (runs.some((run) => run.status === "error")) return "message.cliActivityFailedMany";
return active && runs.some((run) => run.status === "running")
? "message.cliActivityRunningMany"
: "message.cliActivityRanMany";
}
function cliActivityManySummaryDefault(runs: CliRunSummary[], active: boolean): string {
if (runs.some((run) => run.status === "error")) return "{{count}} CLI failed";
return `${active && runs.some((run) => run.status === "running") ? "Running" : "Ran"} {{count}} CLIs`;
}
function cliRunLabelKey(run: CliRunSummary, active: boolean): string {
if (run.status === "error") return "message.cliRunFailed";
return active && run.status === "running" ? "message.cliRunRunning" : "message.cliRunRan";
}
function cliRunLabelDefault(run: CliRunSummary, active: boolean): string {
if (run.status === "error") return "CLI failed";
return active && run.status === "running" ? "Running CLI" : "Ran CLI";
}
function fileActivityVerb(editing: boolean, failed: boolean): string {
if (failed) return "Failed";
return editing ? "Editing" : "Edited";
@@ -519,6 +761,120 @@ function hasVisibleDiffStats(edit: Pick<FileEditSummary, "added" | "deleted">):
return edit.added > 0 || edit.deleted > 0;
}
function CliRunGroup({
runs,
active,
cliAppsByName,
}: {
runs: CliRunSummary[];
active: boolean;
cliAppsByName: Map<string, CliAppInfo>;
}) {
if (runs.length === 0) return null;
return (
<ul className="space-y-1 border-l border-cyan-500/20 pl-3" data-testid="activity-cli-runs">
{runs.map((run) => (
<CliRunRow
key={run.key}
run={run}
active={active}
app={cliAppsByName.get(run.name.toLowerCase())}
/>
))}
</ul>
);
}
function CliRunRow({ run, active, app }: { run: CliRunSummary; active: boolean; app?: CliAppInfo }) {
const { t } = useTranslation();
const [logoFailed, setLogoFailed] = useState(false);
const args = formatCliArgs(run);
const failed = run.status === "error";
const rowActive = active && run.status === "running";
const color = failed ? "#DC2626" : app?.brand_color || "#0891B2";
const logoUrl = app?.logo_url && !logoFailed ? app.logo_url : null;
return (
<li
className={cn(
"grid min-w-0 grid-cols-[minmax(0,1fr)] rounded-[10px] border px-2.5 py-2 text-xs",
"shadow-[0_6px_18px_rgba(15,23,42,0.045)] transition-colors",
)}
style={{
borderColor: alphaColor(color, rowActive ? 34 : failed ? 28 : 22),
backgroundColor: alphaColor(color, rowActive ? 9 : failed ? 7 : 6),
}}
>
<div className="flex min-w-0 items-center gap-2">
<span
data-testid={`activity-cli-logo-${run.name.toLowerCase()}`}
className={cn(
"grid h-7 w-7 shrink-0 place-items-center overflow-hidden rounded-[8px] border text-[10px] font-semibold text-white",
rowActive && "animate-pulse",
)}
style={{
borderColor: alphaColor(color, 26),
backgroundColor: logoUrl ? "hsl(var(--background))" : color,
boxShadow: `0 0 0 3px ${alphaColor(color, rowActive ? 10 : 6)}`,
}}
>
{logoUrl ? (
<img
src={logoUrl}
alt=""
className="h-[70%] w-[70%] object-contain"
onError={() => setLogoFailed(true)}
/>
) : app ? (
cliAppInitials(app).slice(0, 2)
) : (
<Terminal className="h-3.5 w-3.5" aria-hidden />
)}
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-center gap-1.5">
<StreamingLabelSheen active={rowActive} className="shrink-0 text-[12px]">
{t(cliRunLabelKey(run, active), {
defaultValue: cliRunLabelDefault(run, active),
})}
</StreamingLabelSheen>
<span className="min-w-0 truncate font-mono text-[12px] font-semibold text-foreground/90">
@{run.name}
</span>
{failed ? (
<AlertCircle className="h-3 w-3 shrink-0 text-destructive/75" aria-hidden />
) : null}
</span>
{args ? (
<span className="mt-0.5 block truncate font-mono text-[11px] leading-relaxed text-muted-foreground/82">
{args}
</span>
) : null}
{run.error ? (
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-destructive/70">
{run.error}
</span>
) : null}
{run.workingDir ? (
<span className="mt-0.5 block truncate text-[10.5px] leading-relaxed text-muted-foreground/58">
{run.workingDir}
</span>
) : null}
</span>
</div>
</li>
);
}
function alphaColor(color: string, percent: number): string {
if (/^#[0-9a-f]{6}$/i.test(color)) {
const alpha = Math.round((percent / 100) * 255)
.toString(16)
.padStart(2, "0");
return `${color}${alpha}`;
}
return `color-mix(in srgb, ${color} ${percent}%, transparent)`;
}
function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
if (edits.length === 0) return null;
return (
+367 -38
View File
@@ -9,9 +9,16 @@ import {
} from "react";
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
import {
CliAppMentionToken,
cliAppInitials,
splitCliAppMentionSegments,
type CliAppMentionSegment,
} from "@/components/CliAppMentionText";
import {
Activity,
ArrowUp,
AtSign,
BookOpen,
Check,
ChevronDown,
@@ -41,7 +48,7 @@ import {
} from "@/hooks/useAttachedImages";
import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop";
import type { SendImage, SendOptions } from "@/hooks/useNanobotStream";
import type { SlashCommand, GoalStateWsPayload } from "@/lib/types";
import type { CliAppInfo, GoalStateWsPayload, OutboundCliAppMention, SlashCommand } from "@/lib/types";
import { cn } from "@/lib/utils";
/** ``<input accept>``: aligned with the server's MIME whitelist. SVG is
@@ -62,6 +69,7 @@ interface ThreadComposerProps {
modelLabel?: string | null;
variant?: "thread" | "hero";
slashCommands?: SlashCommand[];
cliApps?: CliAppInfo[];
imageMode?: boolean;
onImageModeChange?: (enabled: boolean) => void;
onStop?: () => void;
@@ -98,6 +106,12 @@ interface SlashPaletteLayout {
maxHeight: number;
}
interface CliAppMentionQuery {
query: string;
start: number;
end: number;
}
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
@@ -167,6 +181,17 @@ function buildGoalMarkdownBody(summary: string, objective: string): string {
return o || s;
}
function cliAppMentionPayload(app: CliAppInfo): OutboundCliAppMention {
return {
name: app.name,
display_name: app.display_name,
category: app.category,
entry_point: app.entry_point,
logo_url: app.logo_url ?? null,
brand_color: app.brand_color ?? null,
};
}
function RunElapsedStrip({
startedAt,
goalState,
@@ -371,6 +396,7 @@ export function ThreadComposer({
modelLabel = null,
variant = "thread",
slashCommands = [],
cliApps = [],
imageMode: controlledImageMode,
onImageModeChange,
onStop,
@@ -382,6 +408,9 @@ export function ThreadComposer({
const [inlineError, setInlineError] = useState<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const [cliAppMenuDismissed, setCliAppMenuDismissed] = useState(false);
const [selectedCliAppIndex, setSelectedCliAppIndex] = useState(0);
const [cursorPosition, setCursorPosition] = useState(0);
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
@@ -491,6 +520,52 @@ export function ThreadComposer({
}, [slashCommands, slashQuery, t]);
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
if (disabled || cliAppMenuDismissed) return null;
const caret = Math.min(Math.max(cursorPosition, 0), value.length);
const beforeCaret = value.slice(0, caret);
const match = /(?:^|\s)@([a-z0-9_-]*)$/i.exec(beforeCaret);
if (!match) return null;
const query = match[1].toLowerCase();
return {
query,
start: caret - query.length - 1,
end: caret,
};
}, [cliAppMenuDismissed, cursorPosition, disabled, value]);
const filteredCliApps = useMemo(() => {
if (!cliAppMention) return [];
return cliApps
.filter((app) => app.installed)
.filter((app) => {
const haystack = [
app.name,
app.display_name,
app.category,
app.description,
app.entry_point,
].join(" ").toLowerCase();
return haystack.includes(cliAppMention.query);
})
.slice(0, 8);
}, [cliAppMention, cliApps]);
const showCliAppMenu = filteredCliApps.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu;
const mentionSegments = useMemo(
() => splitCliAppMentionSegments(value, cliApps),
[cliApps, value],
);
const hasCliMentionDecorations = mentionSegments.some((segment) => segment.kind === "cli");
const activeCliMentionApps = useMemo(() => {
const seen = new Set<string>();
return mentionSegments.flatMap((segment) => {
if (segment.kind !== "cli" || seen.has(segment.app.name)) return [];
seen.add(segment.app.name);
return [segment.app];
});
}, [mentionSegments]);
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
placement: "above",
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
@@ -500,6 +575,10 @@ export function ThreadComposer({
setSelectedCommandIndex(0);
}, [slashQuery]);
useEffect(() => {
setSelectedCliAppIndex(0);
}, [cliAppMention?.query]);
useEffect(() => {
if (selectedCommandIndex >= filteredSlashCommands.length) {
setSelectedCommandIndex(0);
@@ -507,22 +586,29 @@ export function ThreadComposer({
}, [filteredSlashCommands.length, selectedCommandIndex]);
useEffect(() => {
if (!showSlashMenu) return;
if (selectedCliAppIndex >= filteredCliApps.length) {
setSelectedCliAppIndex(0);
}
}, [filteredCliApps.length, selectedCliAppIndex]);
useEffect(() => {
if (!showAnyPalette) return;
const dismissOnPointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && formRef.current?.contains(target)) return;
setSlashMenuDismissed(true);
setCliAppMenuDismissed(true);
};
document.addEventListener("pointerdown", dismissOnPointerDown, true);
return () => {
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
};
}, [showSlashMenu]);
}, [showAnyPalette]);
useLayoutEffect(() => {
if (!showSlashMenu) return;
if (!showAnyPalette) return;
const updateLayout = () => {
const form = formRef.current;
@@ -554,7 +640,7 @@ export function ThreadComposer({
window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true);
};
}, [filteredSlashCommands.length, showSlashMenu]);
}, [filteredCliApps.length, filteredSlashCommands.length, showAnyPalette]);
useEffect(() => {
if (!aspectMenuOpen) return;
@@ -602,12 +688,36 @@ export function ThreadComposer({
(command: SlashCommand) => {
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
);
const chooseCliApp = useCallback(
(app: CliAppInfo) => {
if (!cliAppMention) return;
const suffix = value.slice(cliAppMention.end);
const mention = `@${app.name}${suffix.startsWith(" ") ? "" : " "}`;
const next = `${value.slice(0, cliAppMention.start)}${mention}${suffix}`;
const nextCursor = cliAppMention.start + mention.length;
setValue(next);
setCursorPosition(nextCursor);
setCliAppMenuDismissed(true);
setSlashMenuDismissed(false);
setInlineError(null);
resizeTextarea();
requestAnimationFrame(() => {
const el = textareaRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(nextCursor, nextCursor);
});
},
[cliAppMention, resizeTextarea, value],
);
const submit = useCallback(() => {
if (!canSend) return;
const trimmed = value.trim();
@@ -625,14 +735,21 @@ export function ThreadComposer({
preview: { url: img.dataUrl, name: img.file.name },
}))
: undefined;
const options: SendOptions | undefined = imageMode
? {
imageGeneration: {
enabled: true,
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
},
}
: undefined;
const attachedCliApps = activeCliMentionApps.map(cliAppMentionPayload);
const options: SendOptions | undefined =
imageMode || attachedCliApps.length > 0
? {
...(imageMode
? {
imageGeneration: {
enabled: true,
aspect_ratio: imageAspectRatio === "auto" ? null : imageAspectRatio,
},
}
: {}),
...(attachedCliApps.length > 0 ? { cliApps: attachedCliApps } : {}),
}
: undefined;
onSend(trimmed, payload, options);
setValue("");
setInlineError(null);
@@ -640,10 +757,36 @@ export function ThreadComposer({
// preview here without affecting the rendered message.
clear();
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(0);
resizeTextarea();
}, [canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
}, [activeCliMentionApps, canSend, clear, imageAspectRatio, imageMode, onSend, readyImages, resizeTextarea, value]);
const onKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (showCliAppMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
setSelectedCliAppIndex((idx) => (idx + 1) % filteredCliApps.length);
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setSelectedCliAppIndex(
(idx) => (idx - 1 + filteredCliApps.length) % filteredCliApps.length,
);
return;
}
if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) {
e.preventDefault();
chooseCliApp(filteredCliApps[selectedCliAppIndex]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setCliAppMenuDismissed(true);
return;
}
}
if (showSlashMenu) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -719,6 +862,12 @@ export function ThreadComposer({
const attachButtonDisabled = disabled || full;
const showStopButton = isStreaming && !!onStop;
const inputTextClasses = cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
);
return (
<form
@@ -743,6 +892,16 @@ export function ThreadComposer({
onChoose={chooseSlashCommand}
/>
) : null}
{showCliAppMenu ? (
<CliAppMentionPalette
apps={filteredCliApps}
selectedIndex={selectedCliAppIndex}
layout={slashPaletteLayout}
isHero={isHero}
onHover={setSelectedCliAppIndex}
onChoose={chooseCliApp}
/>
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
@@ -787,30 +946,42 @@ export function ThreadComposer({
{runStartedAt != null || goalState?.active ? (
<RunElapsedStrip startedAt={runStartedAt} goalState={goalState} />
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
}}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
"w-full resize-none bg-transparent",
isHero
? "min-h-[78px] px-5 pb-2 pt-5 text-[15px] leading-6"
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
"placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
)}
/>
<div className="relative">
{hasCliMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
isHero={isHero}
className={inputTextClasses}
/>
) : null}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setSlashMenuDismissed(false);
setCliAppMenuDismissed(false);
setCursorPosition(e.target.selectionStart ?? e.target.value.length);
}}
onInput={onInput}
onKeyDown={onKeyDown}
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
inputTextClasses,
"relative z-10 caret-foreground placeholder:text-muted-foreground/70",
"focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed",
hasCliMentionDecorations && "text-transparent selection:bg-primary/20",
)}
/>
</div>
{inlineError ? (
<div
role="alert"
@@ -962,6 +1133,40 @@ export function ThreadComposer({
);
}
function ComposerCliMentionOverlay({
segments,
isHero,
className,
}: {
segments: CliAppMentionSegment[];
isHero: boolean;
className: string;
}) {
return (
<div
aria-hidden
className={cn(
className,
"pointer-events-none absolute inset-0 z-0 overflow-hidden whitespace-pre-wrap break-words text-foreground",
)}
>
{segments.map((segment, index) => {
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
return (
<CliAppMentionToken
key={`cli-${segment.app.name}-${index}`}
app={segment.app}
label={segment.text}
variant="composer"
isHero={isHero}
/>
);
})}
</div>
);
}
interface SlashCommandPaletteProps {
commands: SlashCommand[];
selectedIndex: number;
@@ -971,6 +1176,15 @@ interface SlashCommandPaletteProps {
onChoose: (command: SlashCommand) => void;
}
interface CliAppMentionPaletteProps {
apps: CliAppInfo[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (app: CliAppInfo) => void;
}
function ImageAspectMenu({
selected,
isHero,
@@ -1024,6 +1238,121 @@ function ImageAspectMenu({
);
}
function CliAppMentionPalette({
apps,
selectedIndex,
layout,
isHero,
onHover,
onChoose,
}: CliAppMentionPaletteProps) {
const { t } = useTranslation();
const listMaxHeight = Math.max(
0,
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
);
return (
<div
role="listbox"
aria-label={t("thread.composer.mentions.ariaLabel")}
style={{ maxHeight: layout.maxHeight }}
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="flex items-center gap-1.5 px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
<AtSign className="h-3 w-3" aria-hidden />
<span>{t("thread.composer.mentions.label")}</span>
</div>
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{apps.map((app, index) => {
const selected = index === selectedIndex;
return (
<button
key={app.name}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(app);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<CliAppMentionLogo app={app} selected={selected} />
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
@{app.name}
</span>
<span className="truncate text-[13px] font-medium">
{app.display_name}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{app.category}
{app.entry_point ? ` · ${app.entry_point}` : ""}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
function CliAppMentionLogo({
app,
selected,
}: {
app: CliAppInfo;
selected: boolean;
}) {
const [failed, setFailed] = useState(false);
const color = app.brand_color || "hsl(var(--primary))";
if (app.logo_url && !failed) {
return (
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] border bg-background",
selected ? "border-primary/25" : "border-border/65",
)}
>
<img
src={app.logo_url}
alt=""
className="h-4.5 w-4.5 object-contain"
onError={() => setFailed(true)}
/>
</span>
);
}
return (
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-[8px] text-[10.5px] font-semibold text-white"
style={{ backgroundColor: color }}
>
{cliAppInitials(app)}
</span>
);
}
function SlashCommandPalette({
commands,
selectedIndex,
@@ -6,7 +6,7 @@ import {
AgentActivityCluster,
isAgentActivityMember,
} from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
interface ThreadMessagesProps {
messages: UIMessage[];
@@ -14,6 +14,7 @@ interface ThreadMessagesProps {
isStreaming?: boolean;
hiddenMessageCount?: number;
onLoadEarlier?: () => void;
cliApps?: CliAppInfo[];
}
export type DisplayUnit =
@@ -164,6 +165,7 @@ export function ThreadMessages({
isStreaming = false,
hiddenMessageCount = 0,
onLoadEarlier,
cliApps = [],
}: ThreadMessagesProps) {
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
@@ -208,6 +210,7 @@ export function ThreadMessages({
messages={unit.messages}
isTurnStreaming={index === liveActivityClusterIndex}
hasBodyBelow={hasBodyBelow}
cliApps={cliApps}
/>
) : (
<MessageBubble
@@ -217,6 +220,7 @@ export function ThreadMessages({
? copyFlags[index]
: true
}
cliApps={cliApps}
/>
)}
</div>
+40 -2
View File
@@ -19,8 +19,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice";
import { ThreadViewport } from "@/components/thread/ThreadViewport";
import { useNanobotStream, type SendImage, type SendOptions } from "@/hooks/useNanobotStream";
import { useSessionHistory } from "@/hooks/useSessions";
import { listSlashCommands } from "@/lib/api";
import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types";
import { fetchCliApps, listSlashCommands } from "@/lib/api";
import type { ChatSummary, CliAppInfo, SlashCommand, UIMessage } from "@/lib/types";
import { normalizeLegacyLongTaskMessages } from "@/lib/thread-display-compat";
import { scrubSubagentUiMessages } from "@/lib/subagent-channel-display";
import { useClient } from "@/providers/ClientProvider";
@@ -97,6 +97,7 @@ export function ThreadShell({
const { client, modelName, token } = useClient();
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [cliApps, setCliApps] = useState<CliAppInfo[]>([]);
const [heroImageMode, setHeroImageMode] = useState(false);
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
@@ -247,6 +248,40 @@ export function ThreadShell({
};
}, [token]);
const refreshCliApps = useCallback(async () => {
try {
const payload = await fetchCliApps(token);
setCliApps(payload.apps.filter((app) => app.installed));
} catch {
setCliApps([]);
}
}, [token]);
useEffect(() => {
let cancelled = false;
const load = async () => {
try {
const payload = await fetchCliApps(token);
if (!cancelled) setCliApps(payload.apps.filter((app) => app.installed));
} catch {
if (!cancelled) setCliApps([]);
}
};
load();
const refreshOnFocus = () => {
if (document.visibilityState === "hidden") return;
void refreshCliApps();
};
window.addEventListener("focus", refreshOnFocus);
document.addEventListener("visibilitychange", refreshOnFocus);
return () => {
cancelled = true;
window.removeEventListener("focus", refreshOnFocus);
document.removeEventListener("visibilitychange", refreshOnFocus);
};
}, [refreshCliApps, token]);
const handleWelcomeSend = useCallback(
async (content: string, images?: SendImage[], options?: SendOptions) => {
if (booting) return;
@@ -332,6 +367,7 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
imageMode={showHeroComposer ? heroImageMode : undefined}
onImageModeChange={showHeroComposer ? setHeroImageMode : undefined}
onStop={stop}
@@ -351,6 +387,7 @@ export function ThreadShell({
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
slashCommands={slashCommands}
cliApps={cliApps}
imageMode={heroImageMode}
onImageModeChange={setHeroImageMode}
runStartedAt={runStartedAt}
@@ -391,6 +428,7 @@ export function ThreadShell({
scrollToBottomSignal={scrollToBottomSignal}
conversationKey={historyKey}
showScrollToBottomButton={!!session}
cliApps={cliApps}
/>
</section>
);
@@ -14,7 +14,7 @@ import { ThreadMessages } from "@/components/thread/ThreadMessages";
import { isAgentActivityMember } from "@/components/thread/AgentActivityCluster";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
interface ThreadViewportProps {
messages: UIMessage[];
@@ -24,6 +24,7 @@ interface ThreadViewportProps {
scrollToBottomSignal?: number;
conversationKey?: string | null;
showScrollToBottomButton?: boolean;
cliApps?: CliAppInfo[];
}
const NEAR_BOTTOM_PX = 48;
@@ -53,6 +54,7 @@ export function ThreadViewport({
scrollToBottomSignal = 0,
conversationKey = null,
showScrollToBottomButton = true,
cliApps = [],
}: ThreadViewportProps) {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null);
@@ -249,6 +251,7 @@ export function ThreadViewport({
isStreaming={isStreaming}
hiddenMessageCount={hiddenMessageCount}
onLoadEarlier={loadEarlierMessages}
cliApps={cliApps}
/>
</div>
</div>
+46
View File
@@ -216,6 +216,52 @@
}
}
@keyframes cli-app-linked-sheen {
0% {
transform: translateX(-140%) skewX(-14deg);
opacity: 0;
}
18% {
opacity: 0.7;
}
72%,
100% {
transform: translateX(140%) skewX(-14deg);
opacity: 0;
}
}
.cli-app-linked-chip::after {
content: "";
position: absolute;
inset: -1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.14) 46%,
hsl(var(--background) / 0.7) 50%,
hsl(var(--foreground) / 0.12) 54%,
transparent 100%
);
animation: cli-app-linked-sheen 1.25s ease-out 1;
}
.dark .cli-app-linked-chip::after {
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.12) 46%,
hsl(var(--background) / 0.5) 50%,
hsl(var(--foreground) / 0.1) 54%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.cli-app-linked-chip::after {
animation: none;
content: none;
}
}
/* Subtle scrollbar that doesn't fight the dark background. */
.scrollbar-thin {
scrollbar-width: thin;
+14 -2
View File
@@ -2,10 +2,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useClient } from "@/providers/ClientProvider";
import { toMediaAttachment } from "@/lib/media";
import { mergeUniqueToolTraceLines, toolTraceLinesFromEvents } from "@/lib/tool-traces";
import {
mergeToolProgressEvents,
mergeUniqueToolTraceLines,
normalizeToolProgressEvents,
toolTraceLinesFromEvents,
} from "@/lib/tool-traces";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
@@ -297,6 +303,7 @@ export interface SendImage {
export interface SendOptions {
imageGeneration?: OutboundImageGeneration;
cliApps?: OutboundCliAppMention[];
}
export function useNanobotStream(
@@ -657,6 +664,7 @@ export function useNanobotStream(
// Attach them to the last trace row if it was the last emitted item
// so a sequence of calls collapses into one compact trace group.
if (ev.kind === "tool_hint" || ev.kind === "progress") {
const structuredEvents = normalizeToolProgressEvents(ev.tool_events);
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
const lines = structuredLines.length > 0
? structuredLines
@@ -681,13 +689,15 @@ export function useNanobotStream(
const mergedLines = structuredLines.length > 0
? mergeUniqueToolTraceLines(previousTraces, structuredLines)
: null;
if (mergedLines && !mergedLines.added) return prev;
const merged: UIMessage = {
...last,
traces: mergedLines ? mergedLines.traces : [...previousTraces, ...lines],
content: mergedLines
? mergedLines.traces[mergedLines.traces.length - 1]
: lines[lines.length - 1],
toolEvents: structuredEvents.length
? mergeToolProgressEvents(last.toolEvents, structuredEvents)
: last.toolEvents,
activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
@@ -700,6 +710,7 @@ export function useNanobotStream(
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
...(structuredEvents.length ? { toolEvents: structuredEvents } : {}),
activitySegmentId: segmentId,
createdAt: Date.now(),
},
@@ -836,6 +847,7 @@ export function useNanobotStream(
content,
createdAt: Date.now(),
...(previews ? { images: previews } : {}),
...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}),
},
];
});
+55
View File
@@ -80,6 +80,7 @@
"providers": "Providers",
"image": "Image",
"web": "Web",
"cliApps": "CLI Apps",
"runtime": "Runtime",
"advanced": "Advanced"
},
@@ -94,6 +95,7 @@
"imageDefaults": "Defaults",
"webSearch": "Web search",
"webBehavior": "Behavior",
"cliApps": "CLI Apps",
"identity": "Identity",
"safety": "Safety",
"capabilities": "Capabilities",
@@ -115,6 +117,7 @@
"density": "Density",
"activityMode": "Activity detail",
"codeWrap": "Code wrapping",
"brandLogos": "Brand logos",
"maxResults": "Max results",
"timeout": "Timeout",
"jinaReader": "Jina reader",
@@ -141,6 +144,8 @@
"ssrfWhitelist": "SSRF whitelist",
"mcpServers": "MCP servers",
"pathAppend": "PATH append",
"cliAppsCatalog": "Catalog",
"cliAppsFilter": "Filter",
"configurationDocs": "Configuration docs"
},
"help": {
@@ -154,6 +159,7 @@
"density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.",
"codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.",
"timeout": "Seconds before a search provider request times out.",
"jinaReader": "Use Jina Reader for web_fetch when available.",
@@ -168,8 +174,41 @@
"botIcon": "Short emoji or text shown beside the bot name.",
"timezone": "IANA timezone used by runtime context and schedules.",
"toolHintMaxLength": "Maximum characters shown in tool progress hints.",
"cliAppsCatalog": "Install only the app-specific CLI adapters nanobot can run locally; desktop apps stay untouched.",
"cliAppsFilter": "Search by app, category, or capability.",
"advancedReadOnly": "Advanced safety controls are read-only in WebUI. Edit config.json intentionally when needed."
},
"cliApps": {
"allCategories": "All categories",
"availableCount": "{{count}} apps",
"installedCount": "{{count}} CLIs installed",
"summary": "{{installed}} of {{total}} CLIs installed",
"filterAll": "All",
"filterInstalled": "Installed CLIs",
"filterNotInstalled": "Not installed",
"searchPlaceholder": "Search CLIs",
"loading": "Loading CLI Apps...",
"empty": "No CLI Apps match this filter.",
"statusInstalled": "CLI installed",
"statusMissing": "Missing",
"statusAvailable": "Available",
"statusUnsupported": "Unsupported",
"statusNotInstalled": "CLI not installed",
"requires": "Requires",
"test": "Test CLI",
"update": "Update CLI",
"uninstall": "Uninstall CLI",
"install": "Install CLI",
"readyTitle": "@{{name}} is ready",
"readyStatus": "Ready",
"readyTry": "Try @{{name}}",
"readyCopied": "Copied",
"readyPrompt": "Use @{{name}} to inspect what this CLI can do.",
"openChat": "Open chat",
"unsupported": "Unsupported",
"unavailable": "Unavailable",
"noDescription": "No description available."
},
"values": {
"light": "Light",
"dark": "Dark",
@@ -259,6 +298,9 @@
"searchPlaceholder": "Search providers",
"noMatches": "No providers match this search."
},
"legal": {
"thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."
},
"image": {
"selectProvider": "Select provider",
"selectAspect": "Select aspect",
@@ -462,6 +504,10 @@
}
}
},
"mentions": {
"ariaLabel": "CLI Apps",
"label": "CLI APPS"
},
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@@ -493,6 +539,15 @@
"agentActivityToolsOnly": "{{tools}} tool calls",
"agentActivityLiveSummary": "Working… · {{reasoning}} steps · {{tools}} tool calls",
"agentActivityLiveToolsOnly": "Working… · {{tools}} tool calls",
"cliActivityRunningOne": "Running CLI @{{name}}",
"cliActivityRanOne": "Ran CLI @{{name}}",
"cliActivityFailedOne": "CLI failed @{{name}}",
"cliActivityRunningMany": "Running {{count}} CLIs",
"cliActivityRanMany": "Ran {{count}} CLIs",
"cliActivityFailedMany": "{{count}} CLI failed",
"cliRunRunning": "Running CLI",
"cliRunRan": "Ran CLI",
"cliRunFailed": "CLI failed",
"imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply",
+55
View File
@@ -68,6 +68,7 @@
"providers": "提供商",
"image": "图片",
"web": "网页",
"cliApps": "CLI 应用",
"runtime": "运行时",
"advanced": "高级"
},
@@ -82,6 +83,7 @@
"imageDefaults": "默认值",
"webSearch": "网页搜索",
"webBehavior": "行为",
"cliApps": "CLI 应用",
"identity": "身份",
"safety": "安全",
"capabilities": "能力",
@@ -103,6 +105,7 @@
"density": "密度",
"activityMode": "活动细节",
"codeWrap": "代码换行",
"brandLogos": "品牌 Logo",
"maxResults": "最大结果数",
"timeout": "超时",
"jinaReader": "Jina Reader",
@@ -129,6 +132,8 @@
"ssrfWhitelist": "SSRF 白名单",
"mcpServers": "MCP 服务器",
"pathAppend": "PATH 追加",
"cliAppsCatalog": "目录",
"cliAppsFilter": "筛选",
"configurationDocs": "配置文档"
},
"help": {
@@ -142,6 +147,7 @@
"density": "仅保存在当前浏览器。",
"activityMode": "选择默认显示多少 agent 活动细节。",
"codeWrap": "让较小屏幕上的长代码行更易读。",
"brandLogos": "在设置页显示第三方服务商和 CLI 的 Logo。",
"maxResults": "每次 web_search 返回的结果数量。",
"timeout": "搜索服务商请求超时秒数。",
"jinaReader": "可用时为 web_fetch 使用 Jina Reader。",
@@ -156,8 +162,41 @@
"botIcon": "显示在 bot 名称旁的短 emoji 或文本。",
"timezone": "运行时上下文和计划任务使用的 IANA 时区。",
"toolHintMaxLength": "工具进度提示显示的最大字符数。",
"cliAppsCatalog": "只安装 nanobot 在本机调用应用时需要的 CLI 适配层,不触碰应用本体。",
"cliAppsFilter": "按应用、分类或能力搜索。",
"advancedReadOnly": "高级安全控制在 WebUI 中只读;需要时请谨慎编辑 config.json。"
},
"cliApps": {
"allCategories": "全部分类",
"availableCount": "{{count}} 个应用",
"installedCount": "已安装 {{count}} 个 CLI",
"summary": "CLI 已安装 {{installed}} / {{total}}",
"filterAll": "全部",
"filterInstalled": "已装 CLI",
"filterNotInstalled": "未装 CLI",
"searchPlaceholder": "搜索 CLI",
"loading": "正在加载 CLI 应用...",
"empty": "没有匹配的 CLI 应用。",
"statusInstalled": "CLI 已安装",
"statusMissing": "缺失",
"statusAvailable": "可用",
"statusUnsupported": "暂不支持",
"statusNotInstalled": "CLI 未安装",
"requires": "依赖",
"test": "测试 CLI",
"update": "更新 CLI",
"uninstall": "卸载 CLI",
"install": "安装 CLI",
"readyTitle": "@{{name}} 已就绪",
"readyStatus": "就绪",
"readyTry": "试试 @{{name}}",
"readyCopied": "已复制",
"readyPrompt": "用 @{{name}} 看看这个 CLI 能做什么。",
"openChat": "回到对话",
"unsupported": "暂不支持",
"unavailable": "不可用",
"noDescription": "暂无描述。"
},
"values": {
"light": "浅色",
"dark": "深色",
@@ -247,6 +286,9 @@
"searchPlaceholder": "搜索服务商",
"noMatches": "没有匹配的服务商。"
},
"legal": {
"thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。"
},
"image": {
"selectProvider": "选择服务商",
"selectAspect": "选择比例",
@@ -449,6 +491,10 @@
}
}
},
"mentions": {
"ariaLabel": "CLI 应用",
"label": "CLI 应用"
},
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@@ -481,6 +527,15 @@
"agentActivityToolsOnly": "{{tools}} 次工具调用",
"agentActivityLiveSummary": "进行中… · {{reasoning}} 步 · {{tools}} 次工具调用",
"agentActivityLiveToolsOnly": "进行中… · {{tools}} 次工具调用",
"cliActivityRunningOne": "正在运行 CLI @{{name}}",
"cliActivityRanOne": "已运行 CLI @{{name}}",
"cliActivityFailedOne": "CLI 调用失败 @{{name}}",
"cliActivityRunningMany": "正在运行 {{count}} 个 CLI",
"cliActivityRanMany": "已运行 {{count}} 个 CLI",
"cliActivityFailedMany": "{{count}} 个 CLI 调用失败",
"cliRunRunning": "正在运行 CLI",
"cliRunRan": "已运行 CLI",
"cliRunFailed": "CLI 调用失败",
"imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复",
+19
View File
@@ -1,5 +1,6 @@
import type {
ChatSummary,
CliAppsPayload,
ImageGenerationSettingsUpdate,
ProviderSettingsUpdate,
SettingsPayload,
@@ -106,6 +107,24 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function fetchCliApps(
token: string,
base: string = "",
): Promise<CliAppsPayload> {
return request<CliAppsPayload>(`${base}/api/settings/cli-apps`, token);
}
export async function runCliAppAction(
token: string,
action: "install" | "update" | "uninstall" | "test",
name: string,
base: string = "",
): Promise<CliAppsPayload> {
const query = new URLSearchParams();
query.set("name", name);
return request<CliAppsPayload>(`${base}/api/settings/cli-apps/${action}?${query}`, token);
}
export async function listSlashCommands(
token: string,
base: string = "",
+3 -1
View File
@@ -2,6 +2,7 @@ import type {
ConnectionStatus,
InboundEvent,
Outbound,
OutboundCliAppMention,
OutboundImageGeneration,
OutboundMedia,
GoalStateWsPayload,
@@ -304,7 +305,7 @@ export class NanobotClient {
chatId: string,
content: string,
media?: OutboundMedia[],
options?: { imageGeneration?: OutboundImageGeneration },
options?: { imageGeneration?: OutboundImageGeneration; cliApps?: OutboundCliAppMention[] },
): void {
this.knownChats.add(chatId);
const frame: Outbound = {
@@ -313,6 +314,7 @@ export class NanobotClient {
content,
...(media && media.length > 0 ? { media } : {}),
...(options?.imageGeneration ? { image_generation: options.imageGeneration } : {}),
...(options?.cliApps?.length ? { cli_apps: options.cliApps } : {}),
webui: true,
};
this.queueSend(frame);
+51 -4
View File
@@ -1,3 +1,5 @@
import type { ToolProgressEvent } from "@/lib/types";
/** Drop duplicate tool_call objects (same id or identical formatted trace). */
export function dedupeToolCallsForUi(calls: unknown): unknown[] {
if (!Array.isArray(calls) || calls.length === 0) return [];
@@ -40,15 +42,60 @@ export function formatToolCallTrace(call: unknown): string | null {
}
const VALID_PHASES = new Set(["start", "end", "error"]);
const PHASE_RANK: Record<string, number> = { start: 1, end: 2, error: 3 };
export function toolTraceLinesFromEvents(events: unknown): string[] {
export function normalizeToolProgressEvents(events: unknown): ToolProgressEvent[] {
if (!Array.isArray(events)) return [];
const seen = new Set<string>();
const lines: string[] = [];
const out: ToolProgressEvent[] = [];
for (const event of events) {
if (!event || typeof event !== "object") continue;
const phase = (event as { phase?: unknown }).phase;
const record = event as ToolProgressEvent;
const phase = record.phase;
if (!(phase && typeof phase === "string" && VALID_PHASES.has(phase))) continue;
const name = typeof record.name === "string" ? record.name : "";
const functionName =
typeof (record as { function?: { name?: unknown } }).function?.name === "string"
? String((record as { function?: { name?: unknown } }).function?.name)
: "";
if (!name && !functionName) continue;
out.push(record);
}
return out;
}
function toolEventKey(event: ToolProgressEvent): string {
if (event.call_id) return `call:${event.call_id}`;
return formatToolCallTrace(event) ?? JSON.stringify(event);
}
export function mergeToolProgressEvents(
previous: ToolProgressEvent[] | undefined,
incoming: ToolProgressEvent[],
): ToolProgressEvent[] {
if (!previous?.length) return incoming;
if (!incoming.length) return previous;
const next = [...previous];
const indexByKey = new Map(next.map((event, index) => [toolEventKey(event), index]));
for (const event of incoming) {
const key = toolEventKey(event);
const existingIndex = indexByKey.get(key);
if (existingIndex === undefined) {
indexByKey.set(key, next.length);
next.push(event);
continue;
}
const existing = next[existingIndex];
const incomingRank = PHASE_RANK[String(event.phase)] ?? 0;
const existingRank = PHASE_RANK[String(existing.phase)] ?? 0;
next[existingIndex] = incomingRank >= existingRank ? { ...existing, ...event } : existing;
}
return next;
}
export function toolTraceLinesFromEvents(events: unknown): string[] {
const seen = new Set<string>();
const lines: string[] = [];
for (const event of normalizeToolProgressEvents(events)) {
const callId = (event as { call_id?: unknown }).call_id;
if (callId && typeof callId === "string") {
if (seen.has(callId)) continue;
+52
View File
@@ -40,6 +40,9 @@ export interface UIMessage {
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
/** Structured tool events behind trace rows. Kept so activity cards can
* distinguish running, completed, and failed tool phases. */
toolEvents?: ToolProgressEvent[];
/** Activity rows: explicit file edits emitted by edit tools. */
fileEdits?: UIFileEdit[];
/** Activity rows created during the same agent phase share one collapsible block. */
@@ -48,6 +51,8 @@ export interface UIMessage {
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
media?: UIMediaAttachment[];
/** App-specific CLI adapters explicitly attached to this user turn. */
cliApps?: UICliAppAttachment[];
/** Assistant turn: accumulated model reasoning / thinking text. Built up
* incrementally from ``reasoning_delta`` frames; finalized when
* ``reasoning_end`` arrives. */
@@ -59,6 +64,15 @@ export interface UIMessage {
latencyMs?: number;
}
export interface UICliAppAttachment {
name: string;
display_name?: string;
category?: string;
entry_point?: string;
logo_url?: string | null;
brand_color?: string | null;
}
/** Structured UI blob on ``progress`` WS frames; channels may add more ``kind`` values later. */
export interface AgentUIBlob {
kind: string;
@@ -252,6 +266,34 @@ export interface SettingsPayload {
restart_required_sections?: Array<"runtime" | "web" | "image">;
}
export interface CliAppInfo {
name: string;
display_name: string;
category: string;
description: string;
requires: string;
source: string;
entry_point: string;
install_supported: boolean;
installed: boolean;
available: boolean;
status: "installed" | "missing" | "available" | "unsupported" | "not_installed" | string;
logo_url?: string | null;
brand_color?: string | null;
skill_installed: boolean;
}
export interface CliAppsPayload {
apps: CliAppInfo[];
installed_count: number;
catalog_updated_at?: string | null;
last_action?: {
ok: boolean;
message: string;
output?: string | null;
};
}
export interface SettingsUpdate {
model?: string;
provider?: string;
@@ -394,6 +436,15 @@ export interface OutboundImageGeneration {
aspect_ratio?: string | null;
}
export interface OutboundCliAppMention {
name: string;
display_name?: string;
category?: string;
entry_point?: string;
logo_url?: string | null;
brand_color?: string | null;
}
/** Response shape for ``GET .../webui-thread`` (server-built transcript replay). */
export interface WebuiThreadPersistedPayload {
schemaVersion: number;
@@ -411,6 +462,7 @@ export type Outbound =
content: string;
media?: OutboundMedia[];
image_generation?: OutboundImageGeneration;
cli_apps?: OutboundCliAppMention[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
@@ -2,7 +2,24 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
const BLENDER_CLI_APP: CliAppInfo = {
name: "blender",
display_name: "Blender",
category: "3d",
description: "3D creation",
requires: "",
source: "harness",
entry_point: "cli-anything-blender",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/blender.svg",
brand_color: "#E87D0D",
skill_installed: true,
};
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [
@@ -271,6 +288,67 @@ describe("AgentActivityCluster", () => {
}
});
it("renders CLI app runs as dedicated activity rows", () => {
const line = 'run_cli_app({"name":"blender","args":["--background","scene.blend"],"json":true})';
render(
<AgentActivityCluster
messages={[{
id: "t-cli",
role: "tool",
kind: "trace",
content: line,
traces: [line],
createdAt: 1,
}]}
isTurnStreaming
hasBodyBelow={false}
cliApps={[BLENDER_CLI_APP]}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /running cli @blender/i }));
const cliRuns = screen.getByTestId("activity-cli-runs");
expect(cliRuns).toHaveTextContent("Running CLI");
expect(cliRuns).toHaveTextContent("@blender");
expect(cliRuns).toHaveTextContent("--json --background scene.blend");
expect(screen.getByTestId("activity-cli-logo-blender")).toBeInTheDocument();
expect(screen.queryByText(/run_cli_app/)).not.toBeInTheDocument();
});
it("labels rejected CLI app calls as failed instead of ran", () => {
render(
<AgentActivityCluster
messages={[{
id: "t-cli-fail",
role: "tool",
kind: "trace",
content: 'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
traces: ['run_cli_app({"name":"github","args":["repo","view"],"json":"true"})'],
toolEvents: [
{
phase: "error",
call_id: "call-github",
name: "run_cli_app",
arguments: { name: "github", args: ["repo", "view"], json: "true" },
error: "Error: CLI app 'github' not found",
},
],
createdAt: 1,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
fireEvent.click(screen.getByRole("button", { name: /cli failed @github/i }));
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("CLI failed");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("@github");
expect(screen.getByTestId("activity-cli-runs")).toHaveTextContent("Error: CLI app 'github' not found");
expect(screen.queryByText("Ran CLI")).not.toBeInTheDocument();
});
it("does not render zero diff counters for completed edits", () => {
render(
<AgentActivityCluster
+29
View File
@@ -2,10 +2,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import {
deleteSession,
fetchCliApps,
fetchSidebarState,
fetchWebuiThread,
listSessions,
listSlashCommands,
runCliAppAction,
updateSidebarState,
updateImageGenerationSettings,
updateProviderSettings,
@@ -116,6 +118,33 @@ describe("webui API helpers", () => {
);
});
it("reads CLI Apps catalog and serializes actions", async () => {
vi.mocked(fetch).mockResolvedValueOnce({
ok: true,
json: async () => ({
apps: [],
installed_count: 0,
catalog_updated_at: "2026-04-18",
}),
} as Response);
await expect(fetchCliApps("tok")).resolves.toMatchObject({ apps: [] });
expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await runCliAppAction("tok", "install", "gimp");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/cli-apps/install?name=gimp",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("reads and writes persisted sidebar state", async () => {
const state = {
schema_version: 1,
+4
View File
@@ -727,6 +727,8 @@ describe("App layout", () => {
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
expect(screen.getByText("Brand logos")).toBeInTheDocument();
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
expect(screen.getByText("AI")).toBeInTheDocument();
const modelInput = screen.getByDisplayValue("openai/gpt-4o");
@@ -739,6 +741,8 @@ describe("App layout", () => {
fireEvent.click(within(settingsNav).getByRole("button", { name: "Providers" }));
expect(screen.getByText("OpenRouter")).toBeInTheDocument();
expect(screen.getByText("Ant Ling")).toBeInTheDocument();
expect(screen.getByTestId("provider-logo-openai")).toBeInTheDocument();
expect(screen.getByText(/Product names, logos, and brands/)).toBeInTheDocument();
expect(screen.getAllByText("Not configured").length).toBeGreaterThan(0);
fireEvent.click(screen.getByText("OpenAI"));
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
+83 -1
View File
@@ -2,7 +2,42 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types";
import type { CliAppInfo, UIMessage } from "@/lib/types";
const CLI_APPS: CliAppInfo[] = [
{
name: "zoom",
display_name: "Zoom",
category: "productivity",
description: "Meetings",
requires: "",
source: "harness",
entry_point: "cli-anything-zoom",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/zoom.svg",
brand_color: "#0B5CFF",
skill_installed: true,
},
{
name: "krita",
display_name: "Krita",
category: "image",
description: "Painting",
requires: "",
source: "harness",
entry_point: "cli-anything-krita",
install_supported: true,
installed: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#3BABFF",
skill_installed: false,
},
];
describe("MessageBubble", () => {
it("renders user messages as right-aligned pills", () => {
@@ -22,6 +57,53 @@ describe("MessageBubble", () => {
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
});
it("renders installed CLI app mentions inside sent user messages", () => {
const message: UIMessage = {
id: "u-cli",
role: "user",
content: "Hi nano, please use @zoom to book a meeting, not @krita",
createdAt: Date.now(),
};
render(<MessageBubble message={message} cliApps={CLI_APPS} />);
const token = screen.getByTestId("message-cli-mention-zoom");
expect(token).toHaveTextContent("@zoom");
expect(token.className).not.toContain("rounded");
expect(token.className).not.toContain("px-");
expect(token.getAttribute("style")).toContain("color: #0B5CFF");
expect(token.getAttribute("style")).toContain("text-shadow");
expect(screen.getByTestId("message-cli-mention-logo-zoom")).toBeInTheDocument();
expect(screen.queryByTestId("message-cli-mention-krita")).not.toBeInTheDocument();
expect(screen.getByText(/not @krita/)).toBeInTheDocument();
});
it("renders structured CLI app attachments even without the installed catalog", () => {
const message: UIMessage = {
id: "u-cli-attached",
role: "user",
content: "Please use @drawio for the diagram",
createdAt: Date.now(),
cliApps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagram",
entry_point: "cli-anything-drawio",
logo_url: "https://example.invalid/drawio.svg",
brand_color: "#F08705",
}],
};
render(<MessageBubble message={message} cliApps={[]} />);
const token = screen.getByTestId("message-cli-mention-drawio");
expect(token).toHaveTextContent("@drawio");
expect(token.className).not.toContain("rounded");
expect(token.className).not.toContain("px-");
expect(token.getAttribute("style")).toContain("color: #F08705");
expect(screen.getByTestId("message-cli-mention-logo-drawio")).toBeInTheDocument();
});
it("copies completed assistant replies from the action row", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
+43
View File
@@ -332,6 +332,49 @@ describe("NanobotClient", () => {
);
});
it("includes CLI app attachments in outbound messages", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
client.connect();
lastSocket().fakeOpen();
client.sendMessage(
"chat-cli",
"@drawio please make this diagram",
undefined,
{
cliApps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagrams",
entry_point: "cli-anything-drawio",
logo_url: null,
brand_color: "#F08705",
}],
},
);
expect(lastSocket().sent).toContain(
JSON.stringify({
type: "message",
chat_id: "chat-cli",
content: "@drawio please make this diagram",
cli_apps: [{
name: "drawio",
display_name: "Draw.io",
category: "diagrams",
entry_point: "cli-anything-drawio",
logo_url: null,
brand_color: "#F08705",
}],
webui: true,
}),
);
});
it("re-attaches known chats after a reconnect", async () => {
const client = new NanobotClient({
url: "ws://test",
+173 -5
View File
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ThreadComposer } from "@/components/thread/ThreadComposer";
import type { SlashCommand } from "@/lib/types";
import type { CliAppInfo, SlashCommand } from "@/lib/types";
const COMMANDS: SlashCommand[] = [
{
@@ -19,6 +19,57 @@ const COMMANDS: SlashCommand[] = [
argHint: "[n]",
},
];
const CLI_APPS: CliAppInfo[] = [
{
name: "gimp",
display_name: "GIMP",
category: "image",
description: "Image editing",
requires: "",
source: "harness",
entry_point: "cli-anything-gimp",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: "https://example.invalid/gimp.svg",
brand_color: "#5C5543",
skill_installed: true,
},
{
name: "blender",
display_name: "Blender",
category: "3d",
description: "3D creation",
requires: "",
source: "harness",
entry_point: "cli-anything-blender",
install_supported: true,
installed: true,
available: true,
status: "installed",
logo_url: null,
brand_color: "#E87D0D",
skill_installed: true,
},
{
name: "krita",
display_name: "Krita",
category: "image",
description: "Painting",
requires: "",
source: "harness",
entry_point: "cli-anything-krita",
install_supported: true,
installed: false,
available: false,
status: "not_installed",
logo_url: null,
brand_color: "#3BABFF",
skill_installed: false,
},
];
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
@@ -66,7 +117,7 @@ describe("ThreadComposer", () => {
const input = screen.getByPlaceholderText("Ask anything...");
expect(input).toBeInTheDocument();
expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[58rem]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
@@ -81,9 +132,9 @@ describe("ThreadComposer", () => {
expect(screen.getByText("gpt-4o")).toBeInTheDocument();
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(input.parentElement?.parentElement?.className).toContain("max-w-[49.5rem]");
expect(input.parentElement?.parentElement?.className).toContain("rounded-[22px]");
expect(input.parentElement?.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]");
expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card");
expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground");
});
@@ -163,6 +214,123 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("opens the CLI app mention palette and inserts the selected app", () => {
const onSend = vi.fn();
render(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
const palette = screen.getByRole("listbox", { name: "CLI Apps" });
expect(palette).toBeInTheDocument();
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.queryByRole("option", { name: /@krita/i })).not.toBeInTheDocument();
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByRole("option", { name: /@blender/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(input).toHaveValue("@blender ");
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
expect(screen.queryByRole("listbox", { name: "CLI Apps" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
expect(onSend).toHaveBeenCalledWith("@blender", undefined, {
cliApps: [{
name: "blender",
display_name: "Blender",
category: "3d",
entry_point: "cli-anything-blender",
logo_url: null,
brand_color: "#E87D0D",
}],
});
});
it("completes a CLI app mention with Tab and adds exactly one trailing space", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "use @ble", selectionStart: 8 },
});
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("use @blender ");
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
});
it("does not duplicate the next word separator when completing a CLI app mention", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "use @ble tonight", selectionStart: 8 },
});
fireEvent.keyDown(input, { key: "Tab" });
expect(input).toHaveValue("use @blender tonight");
});
it("renders a CLI app mention logo inline without moving the text cursor slot", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
cliApps={CLI_APPS}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, {
target: { value: "meeting in @gimp", selectionStart: 16 },
});
expect(input).toHaveValue("meeting in @gimp");
const token = screen.getByTestId("composer-cli-mention-gimp");
expect(token).toHaveTextContent("@gimp");
expect(token.className).not.toContain("font-semibold");
expect(token.className).not.toContain("zoom-in");
expect(token.className).not.toContain("px-");
expect(token.className).not.toContain("mx-");
expect(token.getAttribute("style")).toContain("color: #5C5543");
expect(token.getAttribute("style")).toContain("text-shadow");
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
const logo = screen.getByTestId("composer-cli-mention-logo-gimp");
expect(logo.className).toContain("top-1/2");
expect(logo.className).toContain("left-1/2");
expect(logo.className).not.toContain("-top-");
});
it("opens the slash command palette downward when there is more room below", async () => {
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
rect({ top: 40, bottom: 160, width: 800, height: 120 }),
+52
View File
@@ -356,6 +356,58 @@ describe("useNanobotStream", () => {
'exec({"cmd":"ls"})',
'read_file({"path":"notes.md"})',
]);
expect(result.current.messages[0].toolEvents).toMatchObject([
{ phase: "end", call_id: "call-exec", name: "exec" },
{ phase: "error", call_id: "call-read", name: "read_file", error: "missing" },
]);
});
it("keeps phase updates when a tool event trace line is deduped", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-tool-phase", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const args = { name: "github", args: ["repo", "view"], json: "true" };
act(() => {
fake.emit("chat-tool-phase", {
event: "message",
chat_id: "chat-tool-phase",
text: "",
kind: "tool_hint",
tool_events: [{
phase: "start",
call_id: "call-cli",
name: "run_cli_app",
arguments: args,
}],
});
fake.emit("chat-tool-phase", {
event: "message",
chat_id: "chat-tool-phase",
text: "",
kind: "progress",
tool_events: [{
phase: "error",
call_id: "call-cli",
name: "run_cli_app",
arguments: args,
error: "Error: CLI app 'github' not found",
}],
});
});
expect(result.current.messages[0].traces).toEqual([
'run_cli_app({"name":"github","args":["repo","view"],"json":"true"})',
]);
expect(result.current.messages[0].toolEvents).toMatchObject([
{
phase: "error",
call_id: "call-cli",
name: "run_cli_app",
error: "Error: CLI app 'github' not found",
},
]);
});
it("renders live file_edit events as their own activity trace", () => {