Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/components/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ interface ComposerProps {
leading?: ReactNode;
/** Rendered before the voice and send buttons (e.g. model picker) */
trailing?: ReactNode;
/** Always in the toolbar, also in Easy mode (e.g. the context meter) */
status?: ReactNode;
/** Setup chips (folder, machines, profile…): in the tray after `leading`; Easy mode keeps them and `trailing` behind one Options button. */
options?: ReactNode;
size?: "md" | "lg";
Expand Down Expand Up @@ -99,6 +101,7 @@ export function Composer({
onRecall,
leading,
trailing,
status,
options,
size = "md",
draftKey,
Expand Down Expand Up @@ -498,6 +501,7 @@ export function Composer({
)}
</AnimatePresence>

{status}
{easy ? (options || trailing) && <OptionsButton>{options}{trailing}</OptionsButton> : trailing}

<ToolbarButton label={voiceEnabled ? "Voice mode" : "Voice is off — enable it in Settings"} onClick={openVoiceMode} disabled={disabled}>
Expand Down
195 changes: 195 additions & 0 deletions apps/desktop/src/components/chat/context-meter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { Link } from "react-router";
import { motion } from "motion/react";
import { ArrowRight, FoldVertical, Loader2, X } from "lucide-react";
import type { CompactInfo, ConversationContext } from "@godmode/shared";
import { COMPRESS_MIN_TOKENS, DEFAULT_AUTO_COMPACT_PERCENT } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useSettings } from "@/lib/hooks";
import { cn } from "@/lib/utils";

/** Claude Code summarizes by itself close to a full window when Godmode sets no threshold. */
const CLAUDE_DEFAULT_SHARE = 0.9;

export function tokensLabel(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n % 1_000_000 === 0 ? 0 : 1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 100_000 ? 0 : 1)}k`;
return String(n);
}

export interface ContextGauge {
tokens: number;
window: number | null;
/** Share of the window in use, 0..1; null without a window. */
share: number | null;
/** Where Claude summarizes by itself (share of the window); null = never. */
autoAt: number | null;
tone: "calm" | "warn" | "full";
/** Worth a summary at all. */
compressible: boolean;
}

export function useContextGauge(context: ConversationContext | null | undefined): ContextGauge | null {
const pct = useSettings().data?.runner.autoCompactPercent;
if (!context) return null;
const autoAt = pct === 0 ? null : pct === null ? CLAUDE_DEFAULT_SHARE : (pct ?? DEFAULT_AUTO_COMPACT_PERCENT) / 100;
const share = context.window ? Math.min(1, context.tokens / context.window) : null;
const limit = autoAt ?? CLAUDE_DEFAULT_SHARE;
const tone = share === null ? "calm" : share >= limit * 0.95 ? "full" : share >= limit * 0.75 ? "warn" : "calm";
return { tokens: context.tokens, window: context.window, share, autoAt, tone, compressible: context.tokens >= COMPRESS_MIN_TOKENS };
}

const TONE = {
calm: { ring: "text-foreground/55", bar: "bg-foreground/70", text: "text-muted-foreground" },
warn: { ring: "text-warning", bar: "bg-warning", text: "text-warning" },
full: { ring: "text-destructive", bar: "bg-destructive", text: "text-destructive" },
} as const;

function Ring({ share, tone, busy }: { share: number; tone: ContextGauge["tone"]; busy?: boolean }) {
const r = 7;
const c = 2 * Math.PI * r;
return (
<svg viewBox="0 0 18 18" className={cn("size-[17px] -rotate-90", TONE[tone].ring, busy && "animate-spin [animation-duration:1.6s]")} aria-hidden>
<circle cx="9" cy="9" r={r} fill="none" stroke="currentColor" strokeOpacity={0.18} strokeWidth="2.2" />
<motion.circle
cx="9"
cy="9"
r={r}
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeDasharray={c}
initial={false}
animate={{ strokeDashoffset: c * (1 - (busy ? 0.25 : Math.max(0.04, share))) }}
transition={{ duration: 0.6, ease: [0.2, 0.8, 0.2, 1] }}
/>
</svg>
);
}

/** The composer's context ring: how full the chat's session is, with Compress one click away. */
export function ContextMeter({
gauge,
agentName,
running,
compressing,
onCompress,
disabled,
}: {
gauge: ContextGauge;
agentName: string;
running: boolean;
compressing: boolean;
onCompress: () => void;
disabled?: boolean;
}) {
const pct = gauge.share === null ? null : Math.round(gauge.share * 100);
const tone = TONE[gauge.tone];
return (
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
aria-label={pct === null ? `Chat memory: ${tokensLabel(gauge.tokens)} tokens` : `Chat memory ${pct}% full`}
className="flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-1.5 text-[12px] font-medium text-muted-foreground tabular-nums transition outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:bg-accent data-[state=open]:text-foreground"
>
<Ring share={gauge.share ?? 0} tone={gauge.tone} busy={compressing} />
{gauge.tone !== "calm" && pct !== null && <span className={cn("@max-md/composer:hidden", tone.text)}>{pct}%</span>}
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>{compressing ? "Compressing the chat…" : pct === null ? `${tokensLabel(gauge.tokens)} tokens in this chat` : `Chat memory ${pct}% full`}</TooltipContent>
</Tooltip>
<PopoverContent side="top" align="end" className="w-[19rem] p-0">
<div className="px-4 pt-3.5 pb-3">
<div className="eyebrow pb-2">Chat memory</div>
<div className="flex items-baseline justify-between gap-3">
<div className="flex items-baseline gap-1.5">
<span className="text-2xl font-semibold tracking-tight tabular-nums">{tokensLabel(gauge.tokens)}</span>
<span className="text-xs text-muted-foreground">{gauge.window ? `of ${tokensLabel(gauge.window)} tokens` : "tokens"}</span>
</div>
{pct !== null && <span className={cn("text-sm font-medium tabular-nums", tone.text)}>{pct}%</span>}
</div>
{gauge.share !== null && (
<div className="relative mt-2.5 h-1.5 rounded-full bg-foreground/[0.08]">
<motion.div
className={cn("h-full rounded-full", tone.bar)}
initial={false}
animate={{ width: `${Math.max(1.5, gauge.share * 100)}%` }}
transition={{ duration: 0.6, ease: [0.2, 0.8, 0.2, 1] }}
/>
{gauge.autoAt !== null && (
<span className="absolute -top-1 h-3.5 w-px bg-foreground/40" style={{ left: `${gauge.autoAt * 100}%` }} title={`Compresses by itself at ${Math.round(gauge.autoAt * 100)}%`} />
)}
</div>
)}
<p className="mt-3 text-[12.5px] leading-relaxed text-muted-foreground">
Compress turns the conversation so far into a short summary. {agentName} keeps the gist, your instructions and its files, and every next step gets faster
and cheaper.
</p>
</div>
<div className="flex items-center justify-between gap-2 border-t bg-muted/40 px-3 py-2.5">
<Link to="/settings/ai#auto-compact" className="text-[12px] text-muted-foreground transition hover:text-foreground">
{gauge.autoAt === null ? "Auto-compress is off" : `Auto at ${Math.round(gauge.autoAt * 100)}%`}
</Link>
<Button size="sm" variant={gauge.tone === "calm" ? "outline" : "default"} disabled={disabled || compressing || !gauge.compressible} onClick={onCompress} className="h-7 gap-1.5 text-[12.5px]">
{compressing ? <Loader2 className="size-3.5 animate-spin" /> : <FoldVertical className="size-3.5" />}
{compressing ? "Compressing…" : !gauge.compressible ? "Nothing to compress yet" : running ? "Compress after this step" : "Compress now"}
</Button>
</div>
</PopoverContent>
</Popover>
);
}

/** Above the composer once the chat gets long. */
export function CompressBar({ gauge, agentName, running, onCompress, onDismiss }: { gauge: ContextGauge; agentName: string; running: boolean; onCompress: () => void; onDismiss: () => void }) {
const pct = gauge.share === null ? null : Math.round(gauge.share * 100);
const auto = gauge.autoAt !== null ? Math.round(gauge.autoAt * 100) : null;
return (
<div className={cn("mb-2 flex items-center gap-3 rounded-xl border px-3 py-2 text-[13px] shadow-card", gauge.tone === "full" ? "border-destructive/25 bg-destructive/[0.05]" : "border-warning/30 bg-warning/[0.06]")}>
<span className={cn("grid size-7 shrink-0 place-items-center rounded-lg", gauge.tone === "full" ? "bg-destructive/10 text-destructive" : "bg-warning/15 text-warning")}>
<FoldVertical className="size-4" />
</span>
<span className="min-w-0 flex-1 leading-snug">
<span className="font-medium text-foreground">This chat is getting long</span>
<span className="text-muted-foreground">
{" "}
· {tokensLabel(gauge.tokens)} tokens{pct !== null ? ` (${pct}%)` : ""}.{" "}
{auto !== null ? `${agentName} compresses it at ${auto}% by itself.` : "Compress it to keep things quick."}
</span>
</span>
<Button size="xs" onClick={onCompress} className="shrink-0 gap-1">
{running ? "Compress next" : "Compress now"} <ArrowRight className="size-3" />
</Button>
<button type="button" aria-label="Hide" onClick={onDismiss} className="grid size-6 shrink-0 place-items-center rounded-md text-muted-foreground transition hover:bg-foreground/5 hover:text-foreground">
<X className="size-3.5" />
</button>
</div>
);
}

/** Where the conversation was summarized: a quiet divider, earlier messages stay readable above it. */
export function CompactedDivider({ info }: { info: CompactInfo }) {
const saved = info.preTokens && info.postTokens !== null ? Math.max(0, Math.round((1 - info.postTokens / info.preTokens) * 100)) : null;
return (
<motion.div initial={{ opacity: 0, scaleX: 0.9 }} animate={{ opacity: 1, scaleX: 1 }} transition={{ duration: 0.35, ease: [0.2, 0.8, 0.2, 1] }} className="flex items-center gap-3 py-1.5" role="separator">
<span className="h-px flex-1 bg-gradient-to-r from-transparent to-border" />
<span className="inline-flex items-center gap-1.5 rounded-full border bg-card px-2.5 py-1 text-[11.5px] text-muted-foreground shadow-card">
<FoldVertical className="size-3.5 text-success" />
<span className="font-medium text-foreground">{info.auto ? "Compressed automatically" : "Chat compressed"}</span>
{info.preTokens !== null && info.postTokens !== null && (
<span className="tabular-nums">
· {tokensLabel(info.preTokens)} → {tokensLabel(info.postTokens)}
{saved !== null && saved > 0 ? ` (−${saved}%)` : ""}
</span>
)}
</span>
<span className="h-px flex-1 bg-gradient-to-l from-transparent to-border" />
</motion.div>
);
}
13 changes: 9 additions & 4 deletions apps/desktop/src/components/chat/message-blocks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { format } from "date-fns";
import { AnimatePresence, motion } from "motion/react";
import type { Agent, Credential, MessageBlock, ToolTaskAgent } from "@godmode/shared";
import type { Agent, CompactInfo, Credential, MessageBlock, ToolTaskAgent } from "@godmode/shared";
import { WORKFLOW_TOOL, isPlaceholderReply } from "@godmode/shared";
import { ArrowUpRight, Brain, CheckCircle2, ChevronRight, Circle, CircleDot, CornerDownRight, Info, Loader2, Lock, Puzzle, ShieldAlert, Square, SquareSlash, TriangleAlert, Workflow, XCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AgentAvatar } from "@/components/common";
import { ThinkingState } from "@/components/aicss/ThinkingState";
import { ThinkingReasoning } from "@/components/aicss/ThinkingReasoning";
import { FileDiff, diffLines, type DiffRow } from "@/components/aicss/FileDiff";
import { CompactedDivider } from "./context-meter";
import { DrawCheck } from "@/components/aicss/Motion";
import { Orb } from "@/components/aicss/Orb";
import { formatDuration, formatElapsed, formatTokens } from "@/components/runs/run-status";
Expand Down Expand Up @@ -42,7 +43,7 @@ type Item =
| { kind: "text"; key: string; text: string }
| { kind: "thinking"; key: string; text: string }
| { kind: "error"; key: string; text: string }
| { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string; mod?: string }
| { kind: "notice"; key: string; level: "info" | "warning" | "success"; text: string; mod?: string; compacted?: CompactInfo }
| { kind: "command"; key: string; name: string; args: string; output: string }
| { kind: "user-message"; key: string; block: UserMessageBlock }
| { kind: "pause"; key: string; block: PauseBlock }
Expand Down Expand Up @@ -123,8 +124,10 @@ function buildItems(blocks: MessageBlock[]): Item[] {
if (b.type === "text") {
if (b.text.trim()) items.push({ kind: "text", key, text: b.text });
} else if (b.type === "error") items.push({ kind: "error", key, text: b.text });
else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text, mod: b.mod });
else if (b.type === "command") items.push({ kind: "command", key, name: b.name, args: b.args, output: b.output });
else if (b.type === "notice") items.push({ kind: "notice", key, level: b.level, text: b.text, mod: b.mod, compacted: b.compacted });
// The compression divider already says what /compact did.
else if (b.type === "command" && !(b.name === "compact" && blocks.some((x) => x.type === "notice" && x.compacted)))
items.push({ kind: "command", key, name: b.name, args: b.args, output: b.output });
else if (b.type === "user_message") items.push({ kind: "user-message", key: b.id, block: b });
// A run that stands still for a question: the card says so.
else if (b.type === "pause" && b.reason !== "question") items.push({ kind: "pause", key, block: b });
Expand Down Expand Up @@ -213,6 +216,8 @@ export function MessageBlocks({
case "notice":
return item.mod ? (
<ModNote key={item.key} level={item.level} text={item.text} name={item.mod} />
) : item.compacted ? (
<CompactedDivider key={item.key} info={item.compacted} />
) : (
<NoticeItem key={item.key} level={item.level} text={item.text} />
);
Expand Down
20 changes: 14 additions & 6 deletions apps/desktop/src/components/chat/turn-end.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import type { Agent, ConversationWithMessages, Message, RetryMode, Run } from "@godmode/shared";
import { needsFix, retryHelps, retryModeOf, runEndOf, type RunEnd } from "@godmode/shared";
import { ArrowUpRight, LogIn, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react";
import { ArrowUpRight, FoldVertical, LogIn, MessageSquarePlus, Play, RotateCcw, Workflow } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useStartAgentChat } from "@/components/agents/agent-actions";
Expand Down Expand Up @@ -52,6 +52,11 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
const chat = useStartAgentChat();
const runRoutine = useRunRoutine();
const name = agent?.name ?? "The agent";
const compress = useMutation({
mutationFn: () => api.conversations.send(conversation.id, { content: "/compress" }),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.conversation(conversation.id) }),
onError: (err) => toast.error("Couldn't compress the chat", { description: errorMessage(err) }),
});
const retry = useMutation({
mutationFn: () => api.conversations.retry(conversation.id, message.runId!),
onSuccess: (res) => {
Expand Down Expand Up @@ -122,12 +127,15 @@ export function TurnEnd({ conversation, message, agent }: { conversation: Conver
if (!retryHelps(end)) {
return (
<Row>
<span className="min-w-0">
This chat is too long for {name} to go on — type <code className="font-mono text-foreground">/compact</code> to shorten it, or start fresh.
<span className="min-w-0">This chat is too long for {name} to go on. Compress it into a summary and carry on, or start fresh.</span>
<span className="flex shrink-0 items-center gap-1">
<Button size="xs" disabled={compress.isPending} aria-busy={compress.isPending} onClick={() => compress.mutate()}>
{compress.isPending ? <Spinner /> : <FoldVertical />} Compress
</Button>
<Button size="xs" variant="ghost" disabled={chat.isPending} onClick={() => agent && chat.mutate(agent)}>
{chat.isPending ? <Spinner /> : <MessageSquarePlus />} New chat
</Button>
</span>
<Button size="xs" variant="outline" disabled={chat.isPending} onClick={() => agent && chat.mutate(agent)}>
{chat.isPending ? <Spinner /> : <MessageSquarePlus />} New chat
</Button>
</Row>
);
}
Expand Down
26 changes: 25 additions & 1 deletion apps/desktop/src/components/settings/ai-section.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState } from "react";
import { Cpu, Gauge, ShieldAlert, SlidersHorizontal, Sparkles, TerminalSquare } from "lucide-react";
import { toast } from "sonner";
import { DEFAULT_LOOP_REPEATS, DEFAULT_STALL_MINUTES, EFFORT_LABELS, EFFORT_OPTIONS, ULTRACODE_HINT, findModel, type ClaudeModel, type Effort, type Settings } from "@godmode/shared";
import { DEFAULT_AUTO_COMPACT_PERCENT, DEFAULT_LOOP_REPEATS, DEFAULT_STALL_MINUTES, EFFORT_LABELS, EFFORT_OPTIONS, ULTRACODE_HINT, findModel, type ClaudeModel, type Effort, type Settings } from "@godmode/shared";
import { ReasoningEffort } from "@/components/aicss/ReasoningEffort";
import {
AlertDialog,
Expand Down Expand Up @@ -253,6 +253,30 @@ export function AiSection({ settings }: { settings: Settings }) {
<SettingRow label="Run timeout" htmlFor="timeout" description="A run is stopped after this long.">
<NumberField id="timeout" min={1} max={1440} suffix="min" value={r.runTimeoutMinutes} onCommit={(v) => v !== null && patch({ runner: { runTimeoutMinutes: v } })} />
</SettingRow>
<SettingRow
label="Compress long chats"
htmlFor="auto-compact"
description="Once a chat fills this much of the model's memory, it is summarized by itself and goes on from the summary. Earlier keeps every step faster and cheaper; later keeps more detail. Compress by hand any time with the ring next to the model, or /compress."
>
<Select
value={r.autoCompactPercent === undefined ? String(DEFAULT_AUTO_COMPACT_PERCENT) : r.autoCompactPercent === null ? "claude" : String(r.autoCompactPercent)}
onValueChange={(v) => patch({ runner: { autoCompactPercent: v === "claude" ? null : Number(v) } })}
>
<SelectTrigger id="auto-compact" className="w-44">
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{[30, 40, 50, 60, 70, 80].map((p) => (
<SelectItem key={p} value={String(p)}>
At {p}%{p === DEFAULT_AUTO_COMPACT_PERCENT ? " (default)" : ""}
</SelectItem>
))}
<SelectItem value="claude">When nearly full</SelectItem>
<SelectSeparator />
<SelectItem value="0">Never by itself</SelectItem>
</SelectContent>
</Select>
</SettingRow>
<SettingRow
label="Continue after a usage limit"
htmlFor="auto-continue"
Expand Down
Loading
Loading