SLOPSHOPPER

edgee

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

newpanebandcommandprocesstimer
★ 137v0.1.0Apache-2.0updated 2026-10-08edgee-ai/edgee/mods/edgee
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · edgee
│ ┃ edgee-requests ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ◆ EDGEE session — ● DIRECT [ – ] │ ⏺ Read(src/auth.ts) │ ┃ │ Not under edgee launch: requests are │ ⎿ Read 6 lines │ ┃ │ only observed │ ⏺ Update(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Added 2 lines, removed 1 line │ ┃ ┌──────────────────────────────────────────┐ ⏺ Bash(bun test) │ ┃ │ 0 │ ⎿ 3 pass, 1 fail │ ┃ │ requests │ │ ┃ └──────────────────────────────────────────┘ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ cache hit ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ Cost savings │ › /edgee │ ┃ │ │ ⎿ edgee: Not running under Edgee. Start Claude Code with `edgee la │ ┃ │ — │ │ ┃ │ Estimated total saved │ │ ┃ │ — cost reduction │ │ ┃ │ │ │ ┃ │ ● Compression savings — (—) │ │ ┃ │ │ │ ┃ │ ● Routing savings — (—) │ │ ┃ │ │ │ ┃ │ ● Remaining cost — │ │ ┃ │ ⢀⣤⣴⣶⣿⣿⣿⣿⣿⣶⣦⣤⡀ │ │ ┃ │ ⣠⣾⣿⣿⠿⠛⠋⠉⠉⠉⠙⠛⠿⣿⣿⣷⣄ │ ◆ Edgee ● direct · saved ⇄ — est. / compression — · 0 req · 0↑ 0↓ · 0% cache [ + ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ Edgee ● direct · saved ⇄ — est. / compression — · 0 req · 0↑ 0↓ · 0% cache [ + ]
Pane · edgee-requests
╭──────────────────────────────────────────────────────────╮ │ ◆ EDGEE session — ● DIRECT [ – ] │ │ Not under edgee launch: requests are only observed │ ╰──────────────────────────────────────────────────────────╯ ┌──────────────────────────────────────────────────────────┐ │ 0 │ │ requests │ └──────────────────────────────────────────────────────────┘ cache hit ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% ╭──────────────────────────────────────────────────────────╮ │ Cost savings │ │ │ │ — │ │ Estimated total saved │ │ — cost reduction │ │ │ │ ● Compression savings — (—) │ │ │ │ ● Routing savings — (—) │ │ │ │ ● Remaining cost — │ │ ⢀⣤⣴⣶⣿⣿⣿⣿⣿⣶⣦⣤⡀ │ │ ⣠⣾⣿⣿⠿⠛⠋⠉⠉⠉⠙⠛⠿⣿⣿⣷⣄ │ │ ⢀⣾⣿⣿⠟⠁ ⠈⠻⣿⣿⣷⡀ │ │ ⣼⣿⣿⠃ ⠘⣿⣿⣧ │ │ ⣿⣿⣿ — ⣿⣿⣿ │ │ ⢻⣿⣿⡄ ⢠⣿⣿⡟ │ │ ⠈⢿⣿⣿⣦⡀ ⢀⣴⣿⣿⡿⠁ │ │ ⠙⢿⣿⣿⣶⣤⣄⣀⣀⣀⣠⣤⣶⣿⣿⡿⠋ │ │ ⠈⠛⠻⠿⣿⣿⣿⣿⣿⠿⠟⠛⠁ │ │ Session estimates · USD · — unavailable │ ╰──────────────────────────────────────────────────────────╯ ── ROUTE ─────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ Launch with `edgee launch claude` to pick a model │ ╰──────────────────────────────────────────────────────────╯ ── MODELS SERVED ─────────────────────────────────────── none yet ── RECENT REQUESTS ───────────────────────────────────── none yet /edgee panel focus /edgee minimize fold
README

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.

Requests pane

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:

  • the Edgee session id and the active reroute;
  • a model picker: a search field over a list of rows. "Claude Code's choice" (no reroute) is always first; on an empty search, the active and recently served models follow, then providers to browse; while searching, the first 8 matches (provider and name prefixes first, newest version first). ● marks the model in use. Picking reroutes at once, for 24 h; a spinner shows until the gateway confirms;
  • session totals: requests, input, cached and output tokens;
  • a Unicode donut chart of session cost savings, with estimated total saved, percentage cost reduction, and a green/purple/gray legend for compression, routing, and remaining cost. It stacks below the breakdown in narrow panes;
  • session savings in USD: estimated rerouting savings and compression savings (input compression plus output brevity), refreshed every 30 seconds and after each main-agent turn. — means unavailable; a failed refresh marks the last known amounts as stale;
  • per served model: request count and tokens;
  • the last 30 API requests, newest first: time, served model, tokens in→out, duration, ↳ 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.

Keys

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

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.

Shipping

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.

Limits

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.

  • No tool reads a reroute back, so the pane reflects only what this mod set in this session. A reroute changed elsewhere leaves it stale.
  • The reroute has no sourceModel: every request of the session goes to the target, Claude Code's background Haiku calls included.
  • Claude Code's own /model and status line still show the model it asks for.
  • The gateway may name the served model without its provider; the pane finds it in the model list. Token counts are what the gateway's response reports.

Develop

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/edgee
Source 2 files
hooks/edgee.ts 819 lines
1// 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}
819
types/index.d.ts 40 lines
1export 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