Pick the model that serves this Claude Code session through the Edgee gateway: /edgee.

A Claude Code mod that picks the model serving the current session through the Edgee gateway. It calls the session reroute tools of the Edgee MCP server that edgee launch claude injects, with the session id from EDGEE_SESSION_ID.
/edgee show the active reroute
/edgee list [filter] models this session's API key can use
/edgee <model> [minutes] reroute the session (default 60, max 1440)
/edgee off route normally again
/edgee panel open and focus the side pane
/edgee minimize fold the pane into one line above the prompt
<model> matches an exact id (qwen/qwen3-coder-next), a bare name (qwen3-coder-next) or a unique substring (coder-next). While a reroute is active, the pane's header and the minimized band show ⇄ <model> until HH:MM.
Sessions start minimized by default. /edgee panel opens and focuses the pane: docked beside the transcript on a wide terminal, waiting unplaced on a narrow one until the window is wide enough. It shows:
● marks the model in use. Picking reroutes at once, for 24 h; a spinner shows until the gateway confirms;— means unavailable; a failed refresh marks the last known amounts as stale;↳ for subagents, and asked <model> when Claude Code asked for another model than the one that answered (a reroute at work).Each Claude Code API request (turn.step) is recorded; the served model is the one the gateway's response names. Requests made before the mod loaded are not shown, and Claude Code side calls outside the turn loop are not seen.
The pane takes the keyboard when it opens (or with ctrl+x tab, or a click). Type to search; ↑/↓ (or Tab) move the highlight from the search field down the rows, Enter picks the highlighted row (in the search field, the first match). Picking a provider fills the search with it; m (or the [–] button in the header) minimizes the pane; Esc hands the keyboard back to the prompt.
Minimized, the pane closes and one line above the prompt stands in for it: where requests go (● direct, or ⇄ <model> until HH:MM), the last model served, rerouting/compression savings, and the session's requests, tokens and cache hit (as space permits). Its [+] button, or /edgee panel, brings the pane back. The choice is remembered: subsequent sessions restore the last minimized or expanded state.
edgee launch claude bundles this mod: the CLI compiles these files into its binary (src/commands/launch/claude_mods.rs), writes them to ~/.edgee/mods/edgee/ and loads them with --plugin-dir. It does so only when the Edgee MCP server is injected, on Claude Code 2.1.287 or later, and it pre-allows the three reroute tools the mod calls (listSessionModels, setSessionReroute, clearSessionReroute). EDGEE_MODS_DISABLED=1 turns it off.
The mod replaces Edgee's legacy statusline. Without mod support or Edgee MCP, there is no Edgee inline display. Gateway spend, reasoning-token totals, and fallback alerts are not shown by this mod; consult the Edgee console.
Savings come from session analytics through edgee stats --json --session <id>, using the CLI binary and profile that launched Claude. They include the whole gateway session (including background calls) and may lag behind the request timeline. Routing savings are estimates for requests with available pricing; they are not inferred from model names. The CLI needs a signed-in profile to read them. Older launchers without EDGEE_CLI_PATH show unavailable amounts.
sourceModel: every request of the session goes to the target, Claude Code's background Haiku calls included./model and status line still show the model it asks for.claude --plugin-dir ./mods/edgee # hot-reloads on save
claude plugin validate ./mods/edgee
claude plugin test ./mods/edgee
# Type-check: Claude Code writes its declarations to .claude-plugin/types/
# each time it loads the mod (any session with --plugin-dir does), which
# tsconfig.json extends.
npx -p typescript@5 tsc -p ./mods/edgeehooks/edgee.ts 819 lines1// Edgee Model: pick the model that serves this session through the Edgee
2// gateway, using the session reroute tools of the Edgee MCP server that
3// `edgee launch claude` injects.
4//
5// /edgee show the active reroute
6// /edgee list [filter] models this session's API key can use
7// /edgee <model> [minutes] reroute the session (default 60, max 1440)
8// /edgee off route normally again
9// /edgee panel open and focus the side pane: a model selector, each API request and the model that served it
10// /edgee minimize fold the pane into one line above the prompt (its [–] does too; the line's [+] unfolds it)
11
12import type { EngineInterface, On, RenderElement, TextProps, BoxProps, TurnStepInput, TurnStepResult } from "claude-code";
13import type { EdgeeModelTotals, EdgeePicker, EdgeeRequest, EdgeeReroute, EdgeeSavings } from "../types/index.d.ts";
14
15type $ = EngineInterface;
16type Elements = ReturnType<$["ui"]["resolve"]>;
17
18const SERVER = "edgee";
19const COMMAND = "edgee";
20const DEFAULT_MINUTES = 60;
21const PANE_MINUTES = 1440; // a pick in the pane lasts the longest reroute the gateway allows
22const MAX_MINUTES = 1440;
23const LIST_LIMIT = 40;
24const PANE = "edgee-requests";
25const RECENT = 30;
26const OFF = "__off"; // the selector's "no reroute" option
27const CATALOG_ATTEMPTS = 20;
28const CATALOG_RETRY_MS = 1_000;
29const ROWS = 8; // model rows under the filter; past that, typing narrows
30const SUGGESTED_PROVIDERS = 8; // provider chips shown on an empty filter
31const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
32const SPINNER_MS = 80;
33
34// Held by the host, so they survive a hot reload of this file.
35const reroute = { plugin: "edgee", key: "reroute" } as const;
36const requests = { plugin: "edgee", key: "requests" } as const; // the last RECENT requests
37const models = { plugin: "edgee", key: "models" } as const; // session totals per served model
38const catalog = { plugin: "edgee", key: "catalog" } as const; // models the selector offers
39const picker = { plugin: "edgee", key: "picker" } as const; // the selector's filter, duration and last notice
40const minimized = { plugin: "edgee", key: "minimized" } as const; // the pane folded into a line above the prompt
41const savings = { plugin: "edgee", key: "savings" } as const;
42let refreshingSavings = false;
43// Start minimized by default; remember an explicit choice across sessions.
44const MINIMIZED_KEY = "minimized";
45
46export function register(on: On) {
47 on("session.start", async ($, e, next) => {
48 const result = await next(e);
49 await $.command.register({
50 name: COMMAND,
51 description: "Route this session to another model through Edgee",
52 argumentHint: "[list [filter] | <model> [minutes] | off | panel | minimize]",
53 });
54 const folded = (await storedMinimized($)) !== false;
55 await $.state.set(minimized, folded);
56 // Start with the compact line unless the user last left the pane expanded.
57 // Restoring the pane does not take the keyboard.
58 if (!folded) await openPane($, { focus: false });
59 if (await $.env.get("EDGEE_CLI_PATH")) {
60 void refreshSavings($).catch(() => {}); // the host may unload during a refresh
61 // Analytics can arrive after the turn ends; keep refreshing while idle too.
62 $.clock.every(30_000, () => void refreshSavings($).catch(() => {}));
63 }
64 return result;
65 });
66
67 on("turn.complete", async ($, e, next) => {
68 const result = await next(e);
69 if (!e.agentId) {
70 await current($); // drop the reroute once expired, so the pane and band redraw
71 void refreshSavings($).catch(() => {});
72 }
73 return result;
74 });
75
76 // One step is one API request: record what Claude Code asked for and what answered.
77 on("turn.step", async function* ($, e, next) {
78 const startedAt = await $.clock.now();
79 const result = yield* next(e);
80 await record($, e, result, startedAt);
81 return result;
82 });
83
84 on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
85 const elements = $.ui.resolve(e);
86 return paneView(elements, {
87 sessionId: await $.env.get("EDGEE_SESSION_ID"),
88 active: await current($),
89 catalog: (await $.state.get(catalog)).value ?? [],
90 picker: (await $.state.get(picker)).value ?? {},
91 recent: (await $.state.get(requests)).value ?? [],
92 totals: (await $.state.get(models)).value ?? {},
93 savings: (await $.state.get(savings)).value,
94 columns: e.props.bodyColumns,
95 clickable: e.viewport?.isFullscreen === true,
96 isFocused: e.props.isFocused,
97 });
98 });
99
100 // The selector's elements: hooks rather than closures, so the work has `$`.
101 // Each passes first, then acts: the work redraws the pane, and the element
102 // `next` hands the event to would no longer be the one that raised it.
103 on("ui.input", { plugin: "edgee", element: "filter" }, async ($, e, next) => {
104 const result = await next(e);
105 await updatePicker($, { filter: e.value });
106 if (e.kind === "submit") {
107 // Enter picks the first match; on an empty filter it steps into the list.
108 const { value: models = [] } = await $.state.get(catalog);
109 const [first] = e.value.trim() ? matching(models, e.value.trim()) : [];
110 if (first) await pickModel($, first);
111 else if (!e.value.trim()) await $.ui.focus({ requestId: PANE, key: `pick:${OFF}` });
112 }
113 return result;
114 });
115
116 // The list's rows: plain Buttons, so ↑↓ (or Tab) walk them and Enter picks.
117 on("ui.press", { plugin: "edgee", element: /^(pick|provider):/ }, async ($, e, next) => {
118 const result = await next(e);
119 const [kind, value = ""] = splitOnce(e.element, ":");
120 if (kind === "pick") await pickModel($, value);
121 else if (kind === "provider") {
122 await updatePicker($, { filter: `${value}/` });
123 await $.ui.focus({ requestId: PANE, key: "filter" });
124 }
125 return result;
126 });
127
128 // The pane's [–] folds it into one line above the prompt; that line's [+] unfolds it.
129 on("ui.press", { plugin: "edgee", element: "minimize" }, async ($, e, next) => {
130 const result = await next(e);
131 await minimize($);
132 return result;
133 });
134
135 on("ui.press", { plugin: "edgee", element: "expand" }, async ($, e, next) => {
136 const result = await next(e);
137 await openPane($);
138 return result;
139 });
140
141 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
142 if (e.props.hasSurvey || !(await $.state.get(minimized)).value) return next(e);
143 return bandView($.ui.resolve(e), {
144 active: await current($),
145 catalog: (await $.state.get(catalog)).value ?? [],
146 recent: (await $.state.get(requests)).value ?? [],
147 totals: (await $.state.get(models)).value ?? {},
148 savings: (await $.state.get(savings)).value,
149 columns: e.props.bodyColumns,
150 clickable: e.viewport?.isFullscreen === true,
151 });
152 });
153
154 on("command.run", { command: COMMAND }, async ($, e) => {
155 const [verb = "", ...rest] = e.args.trim().split(/\s+/).filter(Boolean);
156 if (verb === "panel") return { text: await openPane($) };
157 if (verb === "minimize") {
158 await minimize($);
159 return { text: `Edgee pane minimized: /${COMMAND} panel restores it.` };
160 }
161 const sessionId = await $.env.get("EDGEE_SESSION_ID");
162 if (!sessionId) {
163 return { text: "Not running under Edgee. Start Claude Code with `edgee launch claude`." };
164 }
165 try {
166 if (verb === "") return { text: await describe($) };
167 if (verb === "list") return { text: await list($, sessionId, rest.join(" ")) };
168 if (verb === "off" || verb === "clear" || verb === "reset") return { text: await clear($, sessionId) };
169 return { text: await set($, sessionId, verb, rest[0]) };
170 } catch (err) {
171 // No `edgee` server (MCP not enabled), or its reroute tools not allowed yet.
172 return { text: `Edgee MCP call failed: ${errorText(err)}` };
173 }
174 });
175}
176
177async function describe($: $): Promise<string> {
178 const active = await current($);
179 if (!active) return `No reroute: requests go to the model Claude Code asks for.\nUsage: /${COMMAND} list [filter] · /${COMMAND} <model> [minutes] · /${COMMAND} off`;
180 return `Rerouted to ${active.model} until ${clockTime(active.expiresAt)}.`;
181}
182
183async function list($: $, sessionId: string, filter: string): Promise<string> {
184 const models = await availableModels($, sessionId);
185 const needle = filter.toLowerCase();
186 const hits = models.filter((m) => m.toLowerCase().includes(needle));
187 if (hits.length === 0) return `No model matches "${filter}".`;
188 if (!needle) {
189 // 200+ models: summarise by provider instead of dumping them all.
190 const byProvider = new Map<string, number>();
191 for (const m of hits) {
192 const provider = m.split("/")[0]!;
193 byProvider.set(provider, (byProvider.get(provider) ?? 0) + 1);
194 }
195 const rows = [...byProvider].map(([p, n]) => ` ${p} (${n})`);
196 return `${hits.length} models available. Narrow with /${COMMAND} list <filter>:\n${rows.join("\n")}`;
197 }
198 const shown = hits.slice(0, LIST_LIMIT).map((m) => ` ${m}`);
199 const more = hits.length > LIST_LIMIT ? `\n … ${hits.length - LIST_LIMIT} more, narrow the filter` : "";
200 return `${shown.join("\n")}${more}`;
201}
202
203async function set($: $, sessionId: string, query: string, minutesArg: string | undefined): Promise<string> {
204 const models = await availableModels($, sessionId);
205 const match = resolveModel(models, query);
206 if (match.error !== undefined) return match.error;
207
208 const minutes = clampMinutes(minutesArg);
209 if (minutes === null) return `Invalid duration "${minutesArg}": give minutes between 1 and ${MAX_MINUTES}.`;
210 return applyReroute($, sessionId, match.model, minutes);
211}
212
213async function applyReroute($: $, sessionId: string, model: string, minutes: number): Promise<string> {
214 await callEdgee($, "setSessionReroute", { sessionId, targetModel: model, durationMinutes: minutes });
215 const expiresAt = (await $.clock.now()) + minutes * 60_000;
216 await $.state.set(reroute, { model, expiresAt });
217 const span = minutes % 60 === 0 ? `${minutes / 60} h` : `${minutes} min`;
218 return `Session rerouted to ${model} for ${span} (until ${clockTime(expiresAt)}).`;
219}
220
221async function clear($: $, sessionId: string): Promise<string> {
222 await callEdgee($, "clearSessionReroute", { sessionId });
223 await $.state.set(reroute, null);
224 return "Reroute cleared: requests go to the model Claude Code asks for.";
225}
226
227async function availableModels($: $, sessionId: string): Promise<string[]> {
228 const value = (await callEdgee($, "listSessionModels", { sessionId })) as { models?: unknown } | null;
229 return Array.isArray(value?.models) ? value.models.filter((m): m is string => typeof m === "string") : [];
230}
231
232// Exact id first, then the bare name without its provider, then a unique substring.
233function resolveModel(models: string[], query: string): { model: string; error?: undefined } | { model?: undefined; error: string } {
234 const q = query.toLowerCase();
235 const exact = models.find((m) => m.toLowerCase() === q || m.toLowerCase().split("/")[1] === q);
236 if (exact) return { model: exact };
237 const partial = models.filter((m) => m.toLowerCase().includes(q));
238 if (partial.length === 1) return { model: partial[0]! };
239 if (partial.length === 0) return { error: `Unknown model "${query}". See /${COMMAND} list <filter>.` };
240 const shown = partial.slice(0, 10).map((m) => ` ${m}`).join("\n");
241 return { error: `"${query}" matches ${partial.length} models, be more specific:\n${shown}` };
242}
243
244function clampMinutes(arg: string | undefined): number | null {
245 if (arg === undefined) return DEFAULT_MINUTES;
246 const n = Number(arg);
247 if (!Number.isInteger(n) || n < 1 || n > MAX_MINUTES) return null;
248 return n;
249}
250
251// Calls one Edgee MCP tool and returns its result parsed from JSON when it is JSON.
252async function callEdgee($: $, tool: string, args: Record<string, unknown>): Promise<unknown> {
253 const result = await $.mcp.call(SERVER, tool, args);
254 const text = (result.content ?? [])
255 .flatMap((b) => (b.type === "text" && typeof b.text === "string" ? [b.text] : []))
256 .join("\n");
257 if (result.isError) throw new Error(text || `${tool} failed`);
258 if (result.structuredContent !== undefined) return result.structuredContent;
259 try {
260 return JSON.parse(text);
261 } catch {
262 return text;
263 }
264}
265
266// The active reroute, or null once it has expired.
267async function current($: $): Promise<EdgeeReroute | null> {
268 const { value } = await $.state.get(reroute);
269 if (!value) return null;
270 if (value.expiresAt <= (await $.clock.now())) {
271 await $.state.set(reroute, null);
272 return null;
273 }
274 return value;
275}
276
277async function openPane($: $, { focus = true } = {}): Promise<string> {
278 await setMinimized($, false);
279 const opened = await $.ui.open({ id: PANE, title: "Edgee requests", ...(focus ? { focus: true } : {}) });
280 // Not awaited: the MCP server may still be connecting; the picker shows a loading state meanwhile.
281 void loadCatalog($);
282 return opened.isPlaced ? "Edgee requests pane opened." : `Edgee requests pane waits: ${opened.reason}`;
283}
284
285async function minimize($: $): Promise<void> {
286 await setMinimized($, true);
287 await $.ui.close({ id: PANE });
288}
289
290async function setMinimized($: $, value: boolean): Promise<void> {
291 if ((await $.state.get(minimized)).value === value) return;
292 await $.state.set(minimized, value);
293 try {
294 await $.store.set(MINIMIZED_KEY, value);
295 } catch {
296 // A preference: losing it means the next session starts minimized.
297 }
298}
299
300async function storedMinimized($: $): Promise<unknown> {
301 try {
302 return await $.store.get(MINIMIZED_KEY);
303 } catch {
304 return undefined;
305 }
306}
307
308// Loads the selector's models; a failure leaves the requests view working.
309// At session start the `edgee` MCP server is often still connecting, so a
310// failed load retries every second for a while before it reports.
311async function loadCatalog($: $, attemptsLeft = CATALOG_ATTEMPTS): Promise<void> {
312 const sessionId = await $.env.get("EDGEE_SESSION_ID");
313 if (!sessionId) return;
314 try {
315 await $.state.set(catalog, await availableModels($, sessionId));
316 const { value } = await $.state.get(picker);
317 if (value?.failed) await updatePicker($, { notice: undefined, failed: false });
318 } catch (err) {
319 if (attemptsLeft > 1) {
320 $.clock.after(CATALOG_RETRY_MS, () => void loadCatalog($, attemptsLeft - 1));
321 return;
322 }
323 await updatePicker($, { notice: `Edgee MCP call failed: ${errorText(err)}`, failed: true });
324 }
325}
326
327// A pick in the pane: reroute to `model`, or clear on OFF; a spinner shows
328// while the gateway applies it, then the outcome shows under the selector.
329async function pickModel($: $, model: string): Promise<void> {
330 const sessionId = await $.env.get("EDGEE_SESSION_ID");
331 let notice: string;
332 let failed = false;
333 if (!sessionId) [notice, failed] = ["Not running under Edgee.", true];
334 else {
335 await updatePicker($, { pending: model, frame: 0, notice: undefined, failed: false });
336 let frame = 0;
337 const spin = $.clock.every(SPINNER_MS, () => void updatePicker($, { frame: ++frame }));
338 try {
339 if (model === OFF) notice = await clear($, sessionId);
340 else {
341 notice = await applyReroute($, sessionId, model, PANE_MINUTES);
342 }
343 } catch (err) {
344 [notice, failed] = [`Edgee MCP call failed: ${errorText(err)}`, true];
345 } finally {
346 spin.cancel();
347 }
348 }
349 await updatePicker($, { notice, failed, pending: undefined });
350}
351
352async function updatePicker($: $, change: Partial<EdgeePicker>): Promise<void> {
353 const { value } = await $.state.get(picker);
354 await $.state.set(picker, { ...value, ...change });
355}
356
357async function record($: $, e: TurnStepInput, result: TurnStepResult | undefined, startedAt: number): Promise<void> {
358 const usage = result?.usage;
359 const entry: EdgeeRequest = {
360 at: startedAt,
361 durationMs: (await $.clock.now()) - startedAt,
362 requested: e.model,
363 served: usage?.model ?? null,
364 input: (usage?.input_tokens ?? 0) + (usage?.cache_creation_input_tokens ?? 0),
365 cached: usage?.cache_read_input_tokens ?? 0,
366 output: usage?.output_tokens ?? 0,
367 subagent: Boolean(e.agentId),
368 };
369 const { value: recent = [] } = await $.state.get(requests);
370 await $.state.set(requests, [...recent, entry].slice(-RECENT));
371
372 const key = entry.served ?? "failed";
373 const { value: totals = {} } = await $.state.get(models);
374 const t: EdgeeModelTotals = totals[key] ?? { requests: 0, input: 0, cached: 0, output: 0 };
375 await $.state.set(models, {
376 ...totals,
377 [key]: {
378 requests: t.requests + 1,
379 input: t.input + entry.input,
380 cached: t.cached + entry.cached,
381 output: t.output + entry.output,
382 },
383 });
384}
385
386// Use the CLI's profile-aware authentication; credentials never enter plugin state.
387// Replace the snapshot each time: the API totals already cover the whole session.
388async function refreshSavings($: $): Promise<void> {
389 if (refreshingSavings) return;
390 refreshingSavings = true;
391 try {
392 const binary = await $.env.get("EDGEE_CLI_PATH");
393 const sessionId = await $.env.get("EDGEE_SESSION_ID");
394 if (!binary || !sessionId) return;
395 const profile = await $.env.get("EDGEE_PROFILE");
396 const result = await $.process.run(
397 [binary, ...(profile ? ["--profile", profile] : []), "stats", "--json", "--session", sessionId],
398 { timeoutMs: 10_000, env: { EDGEE_NO_UPDATE_CHECK: "1" } },
399 );
400 if (result.exitCode !== 0 || result.isStdoutTruncated) throw new Error("Savings unavailable");
401 const value = JSON.parse(result.stdout);
402 const input = nano(value.total_token_cost_savings);
403 const output = nano(value.total_output_cost_savings);
404 await $.state.set(savings, {
405 compression: input !== null && output !== null ? input + output : null,
406 rerouting: nano(value.estimated_routing_savings),
407 remaining: nano(value.total_cost),
408 });
409 } catch {
410 const { value } = await $.state.get(savings);
411 await $.state.set(savings, { compression: null, rerouting: null, remaining: null, ...value, stale: true });
412 } finally {
413 refreshingSavings = false;
414 }
415}
416
417function nano(value: unknown): number | null {
418 return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
419}
420
421function dollars(nano: number | null | undefined): string {
422 if (nano == null) return "—";
423 if (nano > 0 && nano < 10_000_000) return "<$0.01";
424 return `$${(nano / 1_000_000_000).toFixed(2)}`;
425}
426
427// The pane's palette: Edgee's accent, and one hue per provider so a model reads at a glance.
428const ACCENT = "#A78BFA";
429const PROVIDER_COLORS: Record<string, string> = {
430 anthropic: "#D97757",
431 openai: "#10A37F",
432 google: "#4285F4",
433 qwen: "#8B5CF6",
434 deepseek: "#4D6BFE",
435 mistral: "#FA520F",
436 meta: "#3B82F6",
437 xai: "#E5E7EB",
438 zai: "#22D3EE",
439 moonshotai: "#F472B6",
440 minimax: "#F43F5E",
441 nvidia: "#76B900",
442 amazon: "#FF9900",
443};
444
445type PaneData = {
446 sessionId: string | undefined;
447 active: EdgeeReroute | null;
448 catalog: string[];
449 picker: EdgeePicker;
450 recent: EdgeeRequest[];
451 totals: Record<string, EdgeeModelTotals>;
452 savings: EdgeeSavings | undefined;
453 columns: number;
454 clickable: boolean; // the surface reports mouse clicks (fullscreen only): buttons are drawn only there
455 isFocused: boolean;
456};
457
458function paneView(elements: Elements, { sessionId, active, catalog, picker, recent, totals, savings, columns, clickable, isFocused }: PaneData): RenderElement {
459 const { Box, Text, Button } = elements;
460 const inner = Math.max(24, columns - 4); // inside a card's border and padding
461 const span = (children: string, props: Omit<TextProps, "children"> = {}) => Text({ ...props, children });
462 const row = (children: RenderElement[], props: Omit<BoxProps, "children"> = {}) => Box({ flexDirection: "row", ...props, children });
463 const card = (children: RenderElement[], color: string) =>
464 Box({ flexDirection: "column", borderStyle: "round", borderColor: color, paddingX: 1, children });
465 const rule = (title: string) => row([span("── ", { color: ACCENT, dimColor: true }), span(title.toUpperCase(), { color: ACCENT, bold: true }), span(` ${"─".repeat(Math.max(0, columns - title.length - 4))}`, { color: ACCENT, dimColor: true })], { marginTop: 1 });
466
467 // ── Header: who we are and where requests go.
468 const lastAsked = [...recent].reverse().find((r) => !r.subagent)?.requested;
469 const header = card(
470 [
471 row(
472 [
473 row([span("◆ EDGEE", { color: ACCENT, bold: true }), span(` session ${sessionId ? sessionId.slice(0, 8) : "—"}`, { dimColor: true })]),
474 row([
475 picker.pending
476 ? span(` ${spinner(picker)} APPLYING `, { backgroundColor: "#92400E", color: "white", bold: true })
477 : active
478 ? span(" ⇄ REROUTED ", { backgroundColor: "#7C3AED", color: "white", bold: true })
479 : span(" ● DIRECT ", { backgroundColor: "#065F46", color: "white", bold: true }),
480 ...(clickable ? [span(" "), Button({ key: "minimize", label: "–", onPress: () => {} })] : []),
481 ]),
482 ],
483 { justifyContent: "space-between" },
484 ),
485 active
486 ? row([
487 span(fit(bare(lastAsked ?? "claude"), Math.floor(inner / 3)).trimEnd(), { dimColor: true, strikethrough: true }),
488 span(" ━━▶ ", { color: ACCENT, bold: true }),
489 span(active.model, { color: providerColor(active.model), bold: true }),
490 span(` ⏱ ${clockTime(active.expiresAt)}`, { dimColor: true }),
491 ])
492 : span(sessionId ? "No reroute: requests go to the model Claude Code asks for" : "Not under edgee launch: requests are only observed", { dimColor: true, italic: true }),
493 ],
494 active ? ACCENT : "gray",
495 );
496
497 // ── Totals as tiles, then how much of the input the prompt cache served.
498 const sum = sumTotals(totals);
499 const tile = (label: string, value: string, color: string) =>
500 Box({ flexDirection: "column", flexGrow: 1, alignItems: "center", borderStyle: "single", borderColor: color, borderDimColor: true, children: [span(value, { color, bold: true }), span(label, { dimColor: true })] });
501 const hit = cacheHit(sum);
502 const barWidth = Math.max(10, inner - 16);
503 const stats = [
504 row([tile("requests", String(sum.requests), ACCENT), tile("input", short(sum.input), "cyan"), tile("cached", short(sum.cached), "green"), tile("output", short(sum.output), "yellow")]),
505 row([span(" cache hit ", { dimColor: true }), span("█".repeat(Math.round(hit * barWidth)), { color: "green" }), span("░".repeat(barWidth - Math.round(hit * barWidth)), { dimColor: true }), span(` ${Math.round(hit * 100)}%`, { color: "green", bold: true })]),
506 savingsCard(elements, savings, inner),
507 ];
508
509 // ── Route: the selector.
510 const route: RenderElement[] = [];
511 if (catalog.length > 0) {
512 const filter = (picker.filter ?? "").trim().toLowerCase();
513 if ("Input" in elements) {
514 route.push(elements.Input({ key: "filter", label: "⌕", placeholder: "search 200+ models: qwen, opus, gpt-5…", value: picker.filter ?? "", submitLabel: "pick first", autoFocus: true, onSubmit: () => {} }));
515 route.push(span("┄".repeat(inner), { color: ACCENT, dimColor: true }));
516 }
517 route.push(...modelList(elements, { catalog, filter, active, totals, pending: picker.pending, inner }));
518 } else {
519 const waiting = sessionId ? (picker.failed ? "Models unavailable: is the Edgee MCP enabled?" : "Loading models from the Edgee MCP…") : "Launch with `edgee launch claude` to pick a model";
520 route.push(span(waiting, { dimColor: true, italic: true }));
521 }
522 if (picker.pending) {
523 const target = picker.pending === OFF ? "Claude Code's choice" : picker.pending;
524 route.push(row([span(`${spinner(picker)} `, { color: ACCENT, bold: true }), span(picker.pending === OFF ? "Clearing the reroute → " : "Rerouting to ", { dimColor: true }), span(target, { color: providerColor(target), bold: true }), span(" …", { dimColor: true })], { marginTop: 1 }));
525 } else if (picker.notice) route.push(row([span(picker.failed ? "✗ " : "✓ ", { color: picker.failed ? "red" : "green", bold: true }), span(picker.notice, { color: picker.failed ? "red" : undefined, dimColor: !picker.failed })], { marginTop: 1 }));
526
527 // ── Models served: a share bar per model, in its provider's color.
528 const entries = Object.entries(totals).sort((a, b) => b[1].requests - a[1].requests);
529 const nameWidth = Math.max(12, Math.min(34, inner - 30));
530 const shareWidth = Math.max(6, inner - nameWidth - 25);
531 const served = entries.length
532 ? entries.map(([servedAs, t]) => {
533 const model = withProvider(servedAs, catalog);
534 const filled = Math.max(1, Math.round((t.requests / sum.requests) * shareWidth));
535 const color = model === "failed" ? "red" : providerColor(model);
536 return row([
537 span("● ", { color }),
538 span(fit(model, nameWidth), { color, bold: true }),
539 span(` ${"▰".repeat(filled)}`, { color }),
540 span("▱".repeat(shareWidth - filled), { dimColor: true }),
541 span(` ${String(t.requests).padStart(3)} req `, { bold: true }),
542 span(`${short(t.input + t.cached).padStart(6)}↑ ${short(t.output).padStart(5)}↓`, { dimColor: true }),
543 ]);
544 })
545 : [span("none yet", { dimColor: true, italic: true })];
546
547 // ── Recent requests: a timeline, newest first; hovering a row lights it.
548 const reqWidth = Math.max(12, inner - 34);
549 const timeline = recent.length
550 ? [...recent].reverse().map((r, i) => {
551 const served = r.served && withProvider(r.served, catalog);
552 const color = served ? providerColor(served) : "red";
553 const secs = r.durationMs / 1000;
554 const pace = secs < 3 ? "green" : secs < 10 ? "yellow" : "red";
555 const lines = [
556 row([
557 span(clockTime(r.at, true), { dimColor: true }),
558 span(r.subagent ? " ↳ " : " ● ", { color }),
559 span(fit(served || "no response", reqWidth), { color, bold: !r.subagent }),
560 span(` ${short(r.input + r.cached).padStart(6)}`, { color: "cyan" }),
561 span("→", { dimColor: true }),
562 span(short(r.output).padEnd(5), { color: "yellow" }),
563 span(`${secs.toFixed(1).padStart(5)}s`, { color: pace }),
564 ]),
565 ];
566 if (r.served && !sameModel(r.requested, r.served)) {
567 lines.push(row([span(" ╰ ", { dimColor: true }), span(`⇄ from ${r.requested}`, { color: ACCENT, dimColor: true, italic: true })]));
568 }
569 return Box({ key: `req-${i}`, flexDirection: "column", hover: { backgroundColor: "#1F1B2E" }, children: lines });
570 })
571 : [span("none yet", { dimColor: true, italic: true })];
572
573 return Box({
574 flexDirection: "column",
575 children: [
576 header,
577 ...stats,
578 rule("Route"),
579 card(route, "cyan"),
580 rule("Models served"),
581 Box({ flexDirection: "column", paddingX: 1, children: served }),
582 rule("Recent requests"),
583 Box({ flexDirection: "column", paddingX: 1, children: timeline }),
584 row(isFocused
585 ? [span("tab", { color: ACCENT, bold: true }), span(" move ", { dimColor: true }), span("↑↓", { color: ACCENT, bold: true }), span(" choose ", { dimColor: true }), span("⏎", { color: ACCENT, bold: true }), span(" pick ", { dimColor: true }), span("/edgee minimize", { color: ACCENT, bold: true }), span(" ", { dimColor: true }), span("esc", { color: ACCENT, bold: true }), span(" back", { dimColor: true })]
586 : [span("/edgee panel", { color: ACCENT, bold: true }), span(" focus ", { dimColor: true }), span("/edgee minimize", { color: ACCENT, bold: true }), span(" fold", { dimColor: true })], { justifyContent: "center", marginTop: 1 }),
587 ],
588 });
589}
590
591const COMPRESSION_COLOR = "#55B98A";
592const ROUTING_COLOR = "#B12ACB";
593const REMAINING_COLOR = "#60566B";
594
595function savingsCard({ Box, Text }: Elements, savings: EdgeeSavings | undefined, width: number): RenderElement {
596 const compression = savings?.compression;
597 const routing = savings?.rerouting;
598 const remaining = savings?.remaining;
599 const saved = compression != null && routing != null ? compression + routing : null;
600 const baseline = saved != null && remaining != null ? saved + remaining : null;
601 const percent = (value: number | null | undefined) => value != null && baseline != null && baseline > 0
602 ? `${(100 * value / baseline).toFixed(1)}%` : "—";
603 const legend = [
604 ["Compression savings", compression, COMPRESSION_COLOR],
605 ["Routing savings", routing, ROUTING_COLOR],
606 ["Remaining cost", remaining, REMAINING_COLOR],
607 ] as const;
608 const details = Box({ flexDirection: "column", flexGrow: 1, children: [
609 Text({ bold: true, children: dollars(saved) }),
610 Text({ dimColor: true, children: "Estimated total saved" }),
611 Text({ children: `${percent(saved)} cost reduction` }),
612 ...legend.map(([label, value, color]) => Box({ flexDirection: "row", marginTop: 1, children: [
613 Text({ color, children: "● " }),
614 Text({ dimColor: true, children: `${label} ` }),
615 Text({ children: dollars(value) }),
616 ...(label !== "Remaining cost" ? [Text({ dimColor: true, children: ` (${percent(value)})` })] : []),
617 ] })),
618 ] });
619 return Box({ flexDirection: "column", borderStyle: "round", borderColor: ROUTING_COLOR, paddingX: 1, marginTop: 1, children: [
620 Text({ bold: true, children: "Cost savings" }),
621 Box({ flexDirection: width >= 64 ? "row" : "column", alignItems: "center", columnGap: 2, marginTop: 1, children: [
622 details,
623 savingsDonut({ Box, Text }, baseline && saved != null ? [compression! / baseline, routing! / baseline] : [0, 0], percent(saved)),
624 ] }),
625 Text({ dimColor: true, children: savings?.stale ? "Refresh unavailable · last known amounts" : "Session estimates · USD · — unavailable" }),
626 ] });
627}
628
629// Braille provides 2×4 dots per terminal cell: a round ring without image support.
630// Color each cell by its angle; all numeric labels use the exact savings amounts.
631function savingsDonut({ Box, Text }: Pick<Elements, "Box" | "Text">, [compression, routing]: [number, number], label: string): RenderElement {
632 const width = 21;
633 const height = 9;
634 const dots = [[1, 8], [2, 16], [4, 32], [64, 128]];
635 const rows: RenderElement[] = [];
636 for (let y = 0; y < height; y++) {
637 const cells: RenderElement[] = [];
638 for (let x = 0; x < width; x++) {
639 if (y === 4 && x === 7) {
640 cells.push(Text({ bold: true, children: label.padStart(6).padEnd(7) }));
641 x += 6;
642 continue;
643 }
644 let mask = 0;
645 for (let dy = 0; dy < 4; dy++) for (let dx = 0; dx < 2; dx++) {
646 const px = (x * 2 + dx + 0.5 - width) / width;
647 const py = (y * 4 + dy + 0.5 - height * 2) / (height * 2);
648 const radius = px * px + py * py;
649 if (radius >= 0.52 && radius <= 1) mask |= dots[dy]![dx]!;
650 }
651 const angle = (Math.atan2((x + 0.5 - width / 2) / width, -(y + 0.5 - height / 2) / height) / (2 * Math.PI) + 1) % 1;
652 const color = angle < compression ? COMPRESSION_COLOR : angle < compression + routing ? ROUTING_COLOR : REMAINING_COLOR;
653 cells.push(Text({ color, children: mask ? String.fromCharCode(0x2800 + mask) : " " }));
654 }
655 rows.push(Box({ flexDirection: "row", children: cells }));
656 }
657 return Box({ flexDirection: "column", width, flexShrink: 0, children: rows });
658}
659
660// The models containing `filter`, those whose provider or name starts with it
661// first ("qwen" lists qwen/… before deepseek/…-qwen-14b), newest version first.
662function matching(models: string[], filter: string): string[] {
663 const f = filter.toLowerCase();
664 const rank = (m: string) => {
665 const [provider = "", name = m] = m.toLowerCase().split("/");
666 return provider.startsWith(f) ? 0 : name.startsWith(f) ? 1 : 2;
667 };
668 return models.filter((m) => m.toLowerCase().includes(f)).sort((a, b) => rank(a) - rank(b) || b.localeCompare(a, undefined, { numeric: true }));
669}
670
671function spinner(picker: EdgeePicker): string {
672 return SPINNER[(picker.frame ?? 0) % SPINNER.length]!;
673}
674
675type ListData = { catalog: string[]; filter: string; active: EdgeeReroute | null; totals: Record<string, EdgeeModelTotals>; pending: string | undefined; inner: number };
676
677// The picker's rows, each a plain Button: ↑↓ move the highlight, Enter picks.
678// Claude Code's choice is always first. An empty filter offers the active and
679// recently served models, then providers to narrow by; a filter, its matches.
680function modelList({ Box, Text, Button }: Elements, { catalog, filter, active, totals, pending, inner }: ListData): RenderElement[] {
681 const span = (children: string, props: Omit<TextProps, "children"> = {}) => Text({ ...props, children });
682 const chosen = pending ?? active?.model ?? OFF;
683 const option = (value: string, label: string, color: string, note = "") =>
684 Box({
685 flexDirection: "row",
686 children: [
687 span(value === chosen ? "● " : "○ ", { color: value === chosen ? "green" : color, bold: value === chosen }),
688 Button({ key: `pick:${value}`, label: fit(label, Math.max(12, inner - 4 - note.length)).trimEnd(), plain: true, dimColor: value !== chosen, onPress: () => {} }),
689 span(note, { dimColor: true }),
690 ],
691 });
692 const rows = [option(OFF, "Claude Code's choice", "green", " no reroute")];
693
694 if (!filter) {
695 const served = Object.keys(totals).filter((m) => m !== "failed").map((m) => withProvider(m, catalog));
696 const suggested = [...new Set([...(active ? [active.model] : []), ...served])].filter((m) => catalog.includes(m) || m === active?.model);
697 for (const m of suggested.slice(0, ROWS)) rows.push(option(m, m, providerColor(m), m === active?.model ? " active" : " used"));
698 const providers = [...countBy(catalog, (m) => m.split("/")[0] ?? m)].sort((a, b) => b[1] - a[1]).slice(0, SUGGESTED_PROVIDERS);
699 rows.push(
700 Box({
701 flexDirection: "row",
702 flexWrap: "wrap",
703 columnGap: 1,
704 marginTop: 1,
705 children: [span("browse", { dimColor: true }), ...providers.map(([p, n]) => Button({ key: `provider:${p}`, label: `${p} ${n}`, plain: true, onPress: () => {} }))],
706 }),
707 );
708 return rows;
709 }
710
711 const hits = matching(catalog, filter);
712 if (hits.length === 0) return [...rows, span(` no model matches "${filter}"`, { color: "red", italic: true })];
713 for (const m of hits.slice(0, ROWS)) rows.push(option(m, m, providerColor(m), m === active?.model ? " active" : ""));
714 if (hits.length > ROWS) rows.push(span(` +${hits.length - ROWS} more · keep typing to narrow`, { dimColor: true, italic: true }));
715 return rows;
716}
717
718function countBy<T>(items: T[], key: (item: T) => string): Map<string, number> {
719 const counts = new Map<string, number>();
720 for (const item of items) counts.set(key(item), (counts.get(key(item)) ?? 0) + 1);
721 return counts;
722}
723
724function splitOnce(text: string, sep: string): [string, string] {
725 const at = text.indexOf(sep);
726 return at < 0 ? [text, ""] : [text.slice(0, at), text.slice(at + sep.length)];
727}
728
729type BandData = Pick<PaneData, "active" | "catalog" | "recent" | "totals" | "savings" | "columns" | "clickable">;
730
731// The minimized pane: one line above the prompt, where requests go and what they cost.
732function bandView({ Box, Text, Button }: Elements, { active, catalog, recent, totals, savings, columns, clickable }: BandData): RenderElement {
733 const sum = sumTotals(totals);
734 const lastServed = [...recent].reverse().find((r) => r.served)?.served;
735 const served = lastServed ? withProvider(lastServed, catalog) : undefined;
736 const facts = [`saved ⇄ ${dollars(savings?.rerouting)} est. / compression ${dollars(savings?.compression)}${savings?.stale ? " (stale)" : ""}`, `${sum.requests} req`, `${short(sum.input + sum.cached)}↑ ${short(sum.output)}↓`, `${Math.round(cacheHit(sum) * 100)}% cache`].join(" · ");
737 return Box({
738 flexDirection: "row",
739 justifyContent: "space-between",
740 width: columns,
741 children: [
742 Box({
743 flexDirection: "row",
744 flexShrink: 1,
745 children: [
746 Text({ color: ACCENT, bold: true, children: "◆ Edgee " }),
747 active
748 ? Text({ color: ACCENT, bold: true, children: `⇄ ${bare(active.model)} until ${clockTime(active.expiresAt)}` })
749 : Text({ color: "green", children: "● direct" }),
750 ...(served ? [Text({ dimColor: true, children: " · " }), Text({ color: providerColor(served), children: bare(served) })] : []),
751 Text({ dimColor: true, wrap: "truncate-end", children: ` · ${facts}` }),
752 ],
753 }),
754 Box({
755 flexDirection: "row",
756 flexShrink: 0,
757 children: [
758 clickable
759 ? Button({ key: "expand", label: "+", hotkey: "o", onPress: () => {} })
760 : Text({ dimColor: true, children: "/edgee panel" }),
761 ],
762 }),
763 ],
764 });
765}
766
767function sumTotals(totals: Record<string, EdgeeModelTotals>): EdgeeModelTotals {
768 return Object.values(totals).reduce(
769 (s, t) => ({ requests: s.requests + t.requests, input: s.input + t.input, cached: s.cached + t.cached, output: s.output + t.output }),
770 { requests: 0, input: 0, cached: 0, output: 0 },
771 );
772}
773
774// The share of the input the prompt cache served.
775function cacheHit({ input, cached }: EdgeeModelTotals): number {
776 return input + cached > 0 ? cached / (input + cached) : 0;
777}
778
779// The gateway may name the served model without its provider (`qwen3-coder-next`):
780// find it in the catalog to label and color it like the rest.
781function withProvider(model: string, catalog: string[]): string {
782 return model.includes("/") ? model : (catalog.find((m) => m.endsWith(`/${model}`)) ?? model);
783}
784
785function providerColor(model: string): string {
786 return PROVIDER_COLORS[model.split("/")[0] ?? ""] ?? (model.startsWith("claude") ? "#D97757" : "white");
787}
788
789function bare(model: string): string {
790 return model.replace(/\[.*\]$/, "").split("/").pop() ?? model;
791}
792
793// `claude-opus-5-5[1m]` and `anthropic/claude-opus-5-5-20260101` are the same model.
794function sameModel(requested: string, served: string): boolean {
795 const a = bare(requested).toLowerCase();
796 const b = bare(served).toLowerCase();
797 return a.startsWith(b) || b.startsWith(a);
798}
799
800function errorText(err: unknown): string {
801 return err instanceof Error ? err.message : String(err);
802}
803
804function fit(text: string, width: number): string {
805 return text.length > width ? `${text.slice(0, width - 1)}…` : text.padEnd(width);
806}
807
808function short(n: number): string {
809 if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`;
810 if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`;
811 return String(n);
812}
813
814function clockTime(ms: number, seconds = false): string {
815 const d = new Date(ms);
816 const parts = [d.getHours(), d.getMinutes(), ...(seconds ? [d.getSeconds()] : [])];
817 return parts.map((n) => String(n).padStart(2, "0")).join(":");
818}
819types/index.d.ts 40 lines1export type EdgeeReroute = { model: string; expiresAt: number };
2
3export type EdgeeRequest = {
4 at: number;
5 durationMs: number;
6 requested: string;
7 served: string | null;
8 input: number;
9 cached: number;
10 output: number;
11 subagent: boolean;
12};
13
14export type EdgeePicker = {
15 filter?: string;
16 notice?: string;
17 failed?: boolean;
18 pending?: string; // the model a pick is applying (OFF to clear), until the gateway answers
19 frame?: number; // the pending spinner's frame
20};
21
22export type EdgeeModelTotals = { requests: number; input: number; cached: number; output: number };
23
24// Nano-USD from session analytics; null is unavailable, never an assumed zero.
25export type EdgeeSavings = { compression: number | null; rerouting: number | null; remaining: number | null; stale?: boolean };
26
27declare module "claude-code" {
28 interface PluginState {
29 "edgee": {
30 reroute: EdgeeReroute | null;
31 requests: EdgeeRequest[];
32 models: Record<string, EdgeeModelTotals>;
33 catalog: string[];
34 picker: EdgeePicker;
35 minimized: boolean;
36 savings: EdgeeSavings;
37 };
38 }
39}
40