| import { |
| Context, |
| ContextContent, |
| ContextContentBody, |
| ContextContentFooter, |
| ContextContentHeader, |
| ContextTrigger, |
| } from "@/components/ai-elements/context"; |
| import { Button } from "@/components/ui/button"; |
| import { |
| DropdownMenu, |
| DropdownMenuContent, |
| DropdownMenuItem, |
| DropdownMenuSeparator, |
| DropdownMenuTrigger, |
| } from "@/components/ui/dropdown-menu"; |
| import { Spinner } from "@/components/ui/spinner"; |
| import { cn } from "@/lib/utils"; |
| import { useChat, type UIMessage } from "@ai-sdk/react"; |
| import { |
| Add01Icon, |
| AlertCircleIcon, |
| ArrowDown01Icon, |
| Cancel01Icon, |
| Delete02Icon, |
| FilterIcon, |
| TerminalIcon, |
| } from "@hugeicons/core-free-icons"; |
| import { HugeiconsIcon } from "@hugeicons/react"; |
| import { motion } from "motion/react"; |
| import { useEffect, useMemo } from "react"; |
| import { estimateCost, getModel, getModelContextLimit } from "../config"; |
| import type { SessionMeta } from "../lib/sessions"; |
| import { useAgentsStore } from "../store/agentsStore"; |
| import { getOrCreateChat, useChatStore } from "../store/chatStore"; |
| import { usePlanStore } from "../store/planStore"; |
| import { AgentSwitcher } from "./AgentSwitcher"; |
| import { AiChatView } from "./AiChat"; |
| import { PlanDiffReview } from "./PlanDiffReview"; |
| import { TodoStrip } from "./TodoStrip"; |
|
|
| const SUGGESTIONS = [ |
| { |
| label: "Explain the last error", |
| hint: "Read the terminal buffer", |
| icon: AlertCircleIcon, |
| text: "Explain the last error in the terminal.", |
| }, |
| { |
| label: "Generate a command", |
| hint: "Tell me what you want to do", |
| icon: TerminalIcon, |
| text: "Give me a command to ", |
| }, |
| { |
| label: "Summarize buffer", |
| hint: "Recap recent activity", |
| icon: FilterIcon, |
| text: "Summarize what just happened in the terminal.", |
| }, |
| ]; |
|
|
| export function AiMiniWindow() { |
| const closeMini = useChatStore((s) => s.closeMini); |
| const sessionId = useChatStore((s) => s.activeSessionId); |
| const openPanel = useChatStore((s) => s.openPanel); |
| const expandToPanel = () => { |
| closeMini(); |
| openPanel(); |
| }; |
|
|
| useEffect(() => { |
| const onKey = (e: KeyboardEvent) => { |
| if (e.key === "Escape") { |
| const target = e.target as HTMLElement | null; |
| const tag = target?.tagName; |
| if (tag === "INPUT" || tag === "TEXTAREA") return; |
| closeMini(); |
| } |
| }; |
| window.addEventListener("keydown", onKey); |
| return () => window.removeEventListener("keydown", onKey); |
| }, [closeMini]); |
|
|
| return ( |
| <motion.div |
| initial={{ opacity: 0, y: 12, scale: 0.98 }} |
| animate={{ opacity: 1, y: 0, scale: 1 }} |
| exit={{ opacity: 0, y: 12, scale: 0.98 }} |
| transition={{ type: "spring", stiffness: 320, damping: 32 }} |
| data-ai-mini-window |
| className={cn( |
| "no-scrollbar-deep fixed right-4 bottom-24 z-40 flex h-[42rem] w-[34rem] flex-col overflow-hidden", |
| "rounded-2xl border border-border/40 bg-card/90 shadow-2xl ring-1 ring-black/5 backdrop-blur-2xl dark:ring-white/5", |
| "text-[12px]", |
| )} |
| > |
| <div |
| aria-hidden |
| className="pointer-events-none absolute inset-x-0 top-0 h-32 bg-gradient-to-b from-foreground/[0.03] to-transparent" |
| /> |
| {sessionId ? ( |
| <Body |
| sessionId={sessionId} |
| onClose={closeMini} |
| onExpand={expandToPanel} |
| /> |
| ) : ( |
| <EmptyShell onClose={closeMini} onExpand={expandToPanel} /> |
| )} |
| <PlanDiffReview /> |
| </motion.div> |
| ); |
| } |
|
|
| function Body({ |
| sessionId, |
| onClose, |
| onExpand, |
| }: { |
| sessionId: string; |
| onClose: () => void; |
| onExpand: () => void; |
| }) { |
| const focusInput = useChatStore((s) => s.focusInput); |
| const step = useChatStore((s) => s.agentMeta.step); |
|
|
| const chat = useMemo(() => getOrCreateChat(sessionId), [sessionId]); |
| const helpers = useChat<UIMessage>({ chat }); |
| const isBusy = |
| helpers.status === "submitted" || helpers.status === "streaming"; |
|
|
| return ( |
| <> |
| <Header |
| step={step} |
| isBusy={isBusy} |
| onClose={onClose} |
| onExpand={onExpand} |
| messages={helpers.messages} |
| /> |
| |
| <PlanModeStrip /> |
| |
| <div className="flex min-h-0 flex-1 flex-col"> |
| {helpers.messages.length === 0 ? ( |
| <EmptyState onPick={focusInput} /> |
| ) : ( |
| <div className="flex min-h-0 flex-1 flex-col [&_.text-sm]:text-[12px] [&_p]:leading-relaxed"> |
| <AiChatView |
| messages={helpers.messages} |
| status={helpers.status} |
| error={helpers.error} |
| clearError={helpers.clearError} |
| addToolApprovalResponse={helpers.addToolApprovalResponse} |
| stop={helpers.stop} |
| /> |
| </div> |
| )} |
| </div> |
| |
| <TodoStrip sessionId={sessionId} /> |
| </> |
| ); |
| } |
|
|
| function PlanModeStrip() { |
| const active = usePlanStore((s) => s.active); |
| const queueLen = usePlanStore((s) => s.queue.length); |
| const disable = usePlanStore((s) => s.disable); |
| if (!active) return null; |
| return ( |
| <div className="flex shrink-0 items-center gap-2 border-b border-border/40 bg-muted/40 px-3 py-1.5"> |
| <span className="size-1.5 shrink-0 rounded-full bg-amber-500" /> |
| <span className="text-[11px] font-medium text-foreground">Plan mode</span> |
| <span className="text-[11px] text-muted-foreground"> |
| {queueLen > 0 ? `· ${queueLen} queued` : "· no edits queued"} |
| </span> |
| <span className="flex-1" /> |
| <button |
| type="button" |
| onClick={() => disable()} |
| className="rounded px-1.5 py-0.5 text-[10.5px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground" |
| > |
| Exit |
| </button> |
| </div> |
| ); |
| } |
|
|
| function EmptyShell({ |
| onClose, |
| onExpand, |
| }: { |
| onClose: () => void; |
| onExpand: () => void; |
| }) { |
| return ( |
| <> |
| <Header |
| step={null} |
| isBusy={false} |
| onClose={onClose} |
| onExpand={onExpand} |
| /> |
| <div className="flex flex-1 items-center justify-center text-[11px] text-muted-foreground"> |
| Loading sessions… |
| </div> |
| </> |
| ); |
| } |
|
|
| function Header({ |
| step, |
| isBusy, |
| onClose, |
| messages, |
| }: { |
| step: string | null; |
| isBusy: boolean; |
| onClose: () => void; |
| onExpand: () => void; |
| messages?: UIMessage[]; |
| }) { |
| const customAgents = useAgentsStore((s) => s.customAgents); |
| void customAgents; |
|
|
| return ( |
| <div className="relative flex h-11 shrink-0 items-center justify-between gap-2 border-b border-border/60 px-3"> |
| <div className="flex min-w-0 items-center gap-1.5"> |
| <AgentSwitcher isMiniWindow /> |
| {messages !== undefined ? ( |
| <ContextIndicator messages={messages} /> |
| ) : null} |
| </div> |
| <div className="flex shrink-0 items-center gap-1"> |
| {isBusy ? ( |
| <span className="flex min-w-0 items-center gap-1 text-[10px] text-muted-foreground"> |
| <Spinner className="size-2.5" /> |
| <span className="max-w-32 truncate">{step ?? "Thinking…"}</span> |
| </span> |
| ) : null} |
| <SessionPicker /> |
| <Button |
| type="button" |
| size="icon" |
| variant="ghost" |
| onClick={onClose} |
| className="size-5" |
| aria-label="Close" |
| title="Close (Esc)" |
| > |
| <HugeiconsIcon icon={Cancel01Icon} size={11} strokeWidth={1.75} /> |
| </Button> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function estimateTokens(messages: UIMessage[]): number { |
| let chars = 0; |
| for (const m of messages) { |
| for (const p of m.parts) { |
| if (p.type === "text") { |
| chars += (p as { text?: string }).text?.length ?? 0; |
| } else if (p.type === "reasoning") { |
| chars += (p as { text?: string }).text?.length ?? 0; |
| } else if (typeof p.type === "string" && p.type.startsWith("tool-")) { |
| const tp = p as unknown as { input?: unknown; output?: unknown }; |
| if (tp.input) chars += JSON.stringify(tp.input).length; |
| if (tp.output) chars += JSON.stringify(tp.output).length; |
| } |
| } |
| } |
| return Math.ceil(chars / 4); |
| } |
|
|
| function formatTokens(n: number): string { |
| if (n < 1000) return String(n); |
| if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 1 : 0)}k`; |
| return `${(n / 1_000_000).toFixed(2)}M`; |
| } |
|
|
| function ContextIndicator({ messages }: { messages: UIMessage[] }) { |
| const modelId = useChatStore((s) => s.selectedModelId); |
| const tokens = useChatStore((s) => s.agentMeta.tokens); |
| const estimated = useMemo(() => estimateTokens(messages), [messages]); |
| const reported = tokens.inputTokens + tokens.outputTokens; |
| const used = reported > 0 ? tokens.inputTokens : estimated; |
| const max = getModelContextLimit(modelId); |
| const modelLabel = useMemo(() => { |
| try { |
| return getModel(modelId).label; |
| } catch { |
| return modelId; |
| } |
| }, [modelId]); |
| const cost = estimateCost(modelId, tokens); |
| const cacheRate = |
| tokens.inputTokens > 0 |
| ? Math.round((tokens.cachedInputTokens / tokens.inputTokens) * 100) |
| : 0; |
|
|
| return ( |
| <Context usedTokens={used} maxTokens={max} modelId={modelId}> |
| <ContextTrigger className="h-6 gap-1 px-0 text-[10.5px]" /> |
| <ContextContent className="w-64 text-[11px]"> |
| <ContextContentHeader /> |
| <ContextContentBody> |
| <div className="flex items-center justify-between text-muted-foreground"> |
| <span>Model</span> |
| <span className="font-mono text-foreground">{modelLabel}</span> |
| </div> |
| <div className="mt-1 flex items-center justify-between text-muted-foreground"> |
| <span>{reported > 0 ? "Input" : "Estimated input"}</span> |
| <span className="font-mono text-foreground"> |
| {formatTokens(used)} |
| </span> |
| </div> |
| {reported > 0 && ( |
| <> |
| <div className="flex items-center justify-between text-muted-foreground"> |
| <span>Output</span> |
| <span className="font-mono text-foreground"> |
| {formatTokens(tokens.outputTokens)} |
| </span> |
| </div> |
| {tokens.cachedInputTokens > 0 && ( |
| <div className="flex items-center justify-between text-muted-foreground"> |
| <span>Cache hit</span> |
| <span className="font-mono text-foreground">{cacheRate}%</span> |
| </div> |
| )} |
| {cost != null && ( |
| <div className="flex items-center justify-between text-muted-foreground"> |
| <span>Session cost</span> |
| <span className="font-mono text-foreground"> |
| ${cost.toFixed(cost < 0.01 ? 4 : cost < 1 ? 3 : 2)} |
| </span> |
| </div> |
| )} |
| </> |
| )} |
| <div className="flex items-center justify-between text-muted-foreground"> |
| <span>Window</span> |
| <span className="font-mono text-foreground"> |
| {formatTokens(max)} |
| </span> |
| </div> |
| </ContextContentBody> |
| <ContextContentFooter> |
| <span className="text-[10px] italic text-muted-foreground"> |
| {reported > 0 |
| ? "Reported by provider; cost is an estimate." |
| : "Token count is approximate (chars / 4)."} |
| </span> |
| </ContextContentFooter> |
| </ContextContent> |
| </Context> |
| ); |
| } |
|
|
| function SessionPicker() { |
| const sessions = useChatStore((s) => s.sessions); |
| const activeId = useChatStore((s) => s.activeSessionId); |
| const switchSession = useChatStore((s) => s.switchSession); |
| const newSession = useChatStore((s) => s.newSession); |
| const deleteSession = useChatStore((s) => s.deleteSession); |
|
|
| const active = sessions.find((s) => s.id === activeId) ?? null; |
| if (!active) return null; |
|
|
| const sorted = [...sessions].sort((a, b) => b.updatedAt - a.updatedAt); |
|
|
| return ( |
| <DropdownMenu> |
| <DropdownMenuTrigger asChild> |
| <button |
| type="button" |
| className={cn( |
| "flex min-w-0 max-w-48 items-center gap-1 rounded-md px-1.5 py-1", |
| "text-[11px] text-muted-foreground transition-colors", |
| "hover:bg-accent hover:text-foreground", |
| )} |
| title="Switch session" |
| > |
| <span className="truncate">{active.title || "New chat"}</span> |
| <HugeiconsIcon |
| icon={ArrowDown01Icon} |
| size={10} |
| strokeWidth={2} |
| className="opacity-70" |
| /> |
| </button> |
| </DropdownMenuTrigger> |
| <DropdownMenuContent align="start" className="min-w-56"> |
| <DropdownMenuItem |
| onSelect={() => newSession()} |
| className="gap-2 text-xs" |
| > |
| <HugeiconsIcon icon={Add01Icon} size={12} strokeWidth={1.75} /> |
| New session |
| </DropdownMenuItem> |
| {sorted.length > 0 ? <DropdownMenuSeparator /> : null} |
| {sorted.map((s) => ( |
| <SessionRow |
| key={s.id} |
| session={s} |
| active={s.id === activeId} |
| onSelect={() => switchSession(s.id)} |
| onDelete={() => deleteSession(s.id)} |
| /> |
| ))} |
| </DropdownMenuContent> |
| </DropdownMenu> |
| ); |
| } |
|
|
| function SessionRow({ |
| session, |
| active, |
| onSelect, |
| onDelete, |
| }: { |
| session: SessionMeta; |
| active: boolean; |
| onSelect: () => void; |
| onDelete: () => void; |
| }) { |
| return ( |
| <DropdownMenuItem |
| onSelect={(e) => { |
| // Don't dismiss if user clicked the trash icon — handle below. |
| const target = e.target as HTMLElement | null; |
| if (target?.closest("[data-session-delete]")) { |
| e.preventDefault(); |
| return; |
| } |
| onSelect(); |
| }} |
| className={cn( |
| "group flex items-center justify-between gap-2 text-xs", |
| active && "bg-accent/40", |
| )} |
| > |
| <span className="min-w-0 flex-1 truncate"> |
| {session.title || "New chat"} |
| </span> |
| <button |
| type="button" |
| data-session-delete |
| onClick={(e) => { |
| e.stopPropagation(); |
| onDelete(); |
| }} |
| title="Delete session" |
| className="rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100" |
| > |
| <HugeiconsIcon icon={Delete02Icon} size={11} strokeWidth={1.75} /> |
| </button> |
| </DropdownMenuItem> |
| ); |
| } |
|
|
| function EmptyState({ onPick }: { onPick: (text: string) => void }) { |
| return ( |
| <div className="flex flex-1 flex-col items-center justify-center gap-6 px-8 py-10 text-center"> |
| <img src="/logo.png" alt="Terax" className="size-14 opacity-90" /> |
| <div className="space-y-1.5"> |
| <p className="text-[14px] font-semibold tracking-tight"> |
| Ask Terax anything |
| </p> |
| <p className="max-w-[18rem] text-[11.5px] leading-relaxed text-muted-foreground"> |
| Terax sees the active terminal — cwd, recent commands, and output. |
| </p> |
| </div> |
| <div className="flex w-full flex-col gap-2.5"> |
| {SUGGESTIONS.map((s) => ( |
| <button |
| key={s.label} |
| type="button" |
| onClick={() => onPick(s.text)} |
| className={cn( |
| "group flex items-center gap-2.5 bg-card/70 rounded-lg px-2.5 py-2 border border-border text-left", |
| "transition-colors hover:bg-muted/50 hover:text-foreground", |
| )} |
| > |
| <div className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/70 text-muted-foreground transition-colors group-hover:bg-foreground/5 group-hover:text-foreground"> |
| <HugeiconsIcon icon={s.icon} size={13} strokeWidth={1.75} /> |
| </div> |
| <div className="min-w-0 flex-1"> |
| <div className="text-[12px] font-medium text-foreground"> |
| {s.label} |
| </div> |
| <div className="text-[10.5px] text-muted-foreground"> |
| {s.hint} |
| </div> |
| </div> |
| </button> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|