diff --git a/.claude/launch.json b/.claude/launch.json
new file mode 100644
index 0000000000..448f767a0b
--- /dev/null
+++ b/.claude/launch.json
@@ -0,0 +1,11 @@
+{
+ "version": "0.0.1",
+ "configurations": [
+ {
+ "name": "studio",
+ "runtimeExecutable": "npm",
+ "runtimeArgs": ["--prefix", "studio", "run", "dev"],
+ "port": 3000
+ }
+ ]
+}
diff --git a/studio/src/app/workspace/_components/chat-input.tsx b/studio/src/app/workspace/_components/chat-input.tsx
index 863e38e045..f37428380b 100644
--- a/studio/src/app/workspace/_components/chat-input.tsx
+++ b/studio/src/app/workspace/_components/chat-input.tsx
@@ -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,
@@ -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;
@@ -1149,6 +1159,7 @@ function handleMenuNavKey(
export function ChatInput({
placeholder: placeholderProp,
+ contextMeter,
projects,
selectedProjectId,
onSelectProject,
@@ -1700,6 +1711,7 @@ export function ChatInput({
>
)}
+ {contextMeter}
);
diff --git a/studio/src/app/workspace/chat/_components/chat-view.tsx b/studio/src/app/workspace/chat/_components/chat-view.tsx
index 5f3a4f7637..4b379e245a 100644
--- a/studio/src/app/workspace/chat/_components/chat-view.tsx
+++ b/studio/src/app/workspace/chat/_components/chat-view.tsx
@@ -1268,17 +1268,7 @@ export function ChatView({
)}
-
- {/* B1.1: effective model + approximate context utilisation,
- visible only when the window is known and tokens counted. */}
- {contextInfo && contextInfo.contextWindow > 0 && (
-
- )}
+
onSteerQueued?.(id)}
@@ -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 ? (
+
+ ) : undefined
+ }
modelLockedLabel={
live ? session.model || "Auto-routed" : undefined
}
diff --git a/studio/src/app/workspace/chat/_components/chat-workspace.tsx b/studio/src/app/workspace/chat/_components/chat-workspace.tsx
index 968f178fe7..ce3e10e38d 100644
--- a/studio/src/app/workspace/chat/_components/chat-workspace.tsx
+++ b/studio/src/app/workspace/chat/_components/chat-workspace.tsx
@@ -325,7 +325,7 @@ function DraftView({
-
+
{error &&
{error}
}
{
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();
+ });
+});
diff --git a/studio/src/app/workspace/chat/_components/context-meter.tsx b/studio/src/app/workspace/chat/_components/context-meter.tsx
index 850dec4111..71bc23a0d5 100644
--- a/studio/src/app/workspace/chat/_components/context-meter.tsx
+++ b/studio/src/app/workspace/chat/_components/context-meter.tsx
@@ -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";
/**
@@ -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;
@@ -45,26 +48,30 @@ export function ContextMeter({
if (fraction === null || inputTokens + outputTokens <= 0) return null;
const percent = Math.round(fraction * 100);
return (
-
- {modelLabel && {modelLabel}}
-
- = 0.85 ? "bg-warning" : "bg-brand/60",
- )}
- style={{ width: `${Math.max(2, percent)}%` }}
- />
-
-
- ~{percent}% of context
-
-
- approximate
-
-
+
+
+
+
+ = 0.85 ? "bg-warning" : "bg-brand/60",
+ )}
+ style={{ width: `${Math.max(2, percent)}%` }}
+ />
+
+
+ {percent}% used
+
+
+
+
+ Roughly how much of the model's working memory this chat has used.
+ When it fills up, older messages are summarized to make room.
+
+
);
}