Skip to content
11 changes: 11 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "studio",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "studio", "run", "dev"],
"port": 3000
}
]
}
14 changes: 13 additions & 1 deletion studio/src/app/workspace/_components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ import {
Shield,
SlidersHorizontal,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
Expand Down Expand Up @@ -63,6 +70,9 @@ interface ProjectItem {
interface ChatInputProps {
placeholder?: string;
rows?: number;
/** The context-utilisation strip, rendered at the right end of the toolbar
row below the input (typography matches the pills' value text). */
contextMeter?: ReactNode;
projects?: ProjectItem[];
selectedProjectId?: string | null;
onSelectProject?: (id: string | null) => void;
Expand Down Expand Up @@ -1149,6 +1159,7 @@ function handleMenuNavKey(

export function ChatInput({
placeholder: placeholderProp,
contextMeter,
projects,
selectedProjectId,
onSelectProject,
Expand Down Expand Up @@ -1700,6 +1711,7 @@ export function ChatInput({
<MemoryToggle />
</>
)}
{contextMeter}
</div>
</div>
);
Expand Down
24 changes: 13 additions & 11 deletions studio/src/app/workspace/chat/_components/chat-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1268,17 +1268,7 @@ export function ChatView({
</Button>
</div>
)}
<div className="max-w-[768px] space-y-1.5 max-[499px]:max-w-none">
{/* B1.1: effective model + approximate context utilisation,
visible only when the window is known and tokens counted. */}
{contextInfo && contextInfo.contextWindow > 0 && (
<ContextMeter
modelLabel={contextInfo.modelLabel}
contextWindow={contextInfo.contextWindow}
inputTokens={usage?.inputTokens ?? 0}
outputTokens={usage?.outputTokens ?? 0}
/>
)}
<div className="w-full space-y-1.5">
<QueuedMessageStrip
queued={queuedMessages}
onSteer={(id) => onSteerQueued?.(id)}
Expand Down Expand Up @@ -1318,6 +1308,18 @@ export function ChatView({
onPreviewAttachment={handlePreviewFile}
focusKey={session.id}
mobileDocked
// B1.1: effective model + approximate context utilisation,
// leftmost in the toolbar, visible only when the window is
// known and tokens counted.
contextMeter={
contextInfo && contextInfo.contextWindow > 0 ? (
<ContextMeter
contextWindow={contextInfo.contextWindow}
inputTokens={usage?.inputTokens ?? 0}
outputTokens={usage?.outputTokens ?? 0}
/>
) : undefined
}
modelLockedLabel={
live ? session.model || "Auto-routed" : undefined
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,7 +325,7 @@ function DraftView({
</div>
</div>
<div className="absolute bottom-0 left-0 right-0 px-3 lg:px-4 pb-4 max-[499px]:px-0 max-[499px]:pb-0">
<div className="max-w-[768px] space-y-1.5 max-[499px]:max-w-none">
<div className="w-full space-y-1.5">
{error && <p className="px-1 text-sm text-destructive">{error}</p>}
<ChatInput
rows={1}
Expand Down
52 changes: 51 additions & 1 deletion studio/src/app/workspace/chat/_components/context-meter.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { render } from "@testing-library/react";
import { createElement } from "react";
import { describe, expect, it } from "vitest";
import { contextUtilisation } from "./context-meter";
import { ContextMeter, contextUtilisation } from "./context-meter";

/**
* Pins the context-meter math (B1.1): counted input+output tokens over the
Expand All @@ -26,3 +28,51 @@ describe("contextUtilisation", () => {
expect(contextUtilisation(-5, 100, 1_000)).toBeCloseTo(0.1);
});
});

/**
* The rendered strip lives at the right end of the composer toolbar: a short
* bar plus a plain "N% used" (the tooltip owns the approximation caveat and
* explains what a context window is), text matching the toolbar pills' value
* typography (text-sm, muted — the "On" in "Memory On"), no model name.
*/
describe("ContextMeter render", () => {
const props = {
contextWindow: 400_000,
inputTokens: 80_000,
outputTokens: 8_000,
};

it("shows the plain percentage as used, no approximate tag", () => {
const { container } = render(createElement(ContextMeter, props));
expect(container.textContent).toContain("22% used");
expect(container.textContent).not.toContain("~");
expect(container.textContent).not.toContain("of context");
expect(container.textContent).not.toContain("approximate");
});

it("docks right with a short fixed bar and no model name", () => {
const { container } = render(createElement(ContextMeter, props));
const root = container.firstElementChild;
expect(root?.className).toContain("ml-auto");
expect(root?.innerHTML).toContain("w-10");
expect(container.textContent?.trim()).toBe("22% used");
});

it("uses the toolbar pills' value typography", () => {
const { container } = render(createElement(ContextMeter, props));
const root = container.firstElementChild;
expect(root?.className).toContain("text-sm");
expect(root?.className).toContain("text-muted-foreground");
});

it("stays quiet until this visit has counted tokens", () => {
const { container } = render(
createElement(ContextMeter, {
...props,
inputTokens: 0,
outputTokens: 0,
}),
);
expect(container.firstElementChild).toBeNull();
});
});
67 changes: 37 additions & 30 deletions studio/src/app/workspace/chat/_components/context-meter.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
"use client";

/**
* The slim context strip near the composer (B1.1): the session's effective
* model and an APPROXIMATE context-utilisation meter — cumulative input+output
* The context strip at the right end of the composer toolbar (B1.1): a short
* bar plus an APPROXIMATE context-utilisation figure — cumulative input+output
* tokens this visit counted (summed from the runs' terminal usage frames) vs
* the model's `resolved_model.context_window`. Deliberately labeled
* approximate: the daemon's true compaction trigger also counts the system
* prompt and tool schemas, which the client never sees, and a page reload
* loses the visit's running total.
* the model's `resolved_model.context_window`. The reading is approximate —
* the daemon's true compaction trigger also counts the system prompt and tool
* schemas, which the client never sees, and a page reload loses the visit's
* running total — which the tooltip explains. Typography matches the toolbar
* pills' value text (the "On" in "Memory On").
*/

import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

/**
Expand All @@ -28,13 +34,10 @@ export function contextUtilisation(
}

export function ContextMeter({
modelLabel,
contextWindow,
inputTokens,
outputTokens,
}: {
/** The effective model (resolved_model.model_id). */
modelLabel: string;
contextWindow: number;
inputTokens: number;
outputTokens: number;
Expand All @@ -45,26 +48,30 @@ export function ContextMeter({
if (fraction === null || inputTokens + outputTokens <= 0) return null;
const percent = Math.round(fraction * 100);
return (
<div className="flex items-center gap-2 px-2 text-[11px] text-muted-foreground/80">
{modelLabel && <span className="truncate font-medium">{modelLabel}</span>}
<span
className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-border"
aria-hidden="true"
>
<span
className={cn(
"block h-full rounded-full",
fraction >= 0.85 ? "bg-warning" : "bg-brand/60",
)}
style={{ width: `${Math.max(2, percent)}%` }}
/>
</span>
<span className="whitespace-nowrap tabular-nums">
~{percent}% of context
</span>
<span className="whitespace-nowrap text-muted-foreground/60">
approximate
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="ml-auto flex shrink-0 items-center gap-2 px-2 text-sm text-muted-foreground">
<span
className="h-1 w-10 shrink-0 overflow-hidden rounded-full bg-border"
aria-hidden="true"
>
<span
className={cn(
"block h-full rounded-full",
fraction >= 0.85 ? "bg-warning" : "bg-brand/60",
)}
style={{ width: `${Math.max(2, percent)}%` }}
/>
</span>
<span className="whitespace-nowrap tabular-nums">
{percent}% used
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" align="end" className="max-w-64">
Roughly how much of the model&apos;s working memory this chat has used.
When it fills up, older messages are summarized to make room.
</TooltipContent>
</Tooltip>
);
}
Loading