@yusukeshib/pi-colored-model-status 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yusuke Shibata
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # @yusukeshib/pi-colored-model-status
2
+
3
+ A [pi](https://pi.dev) extension that color-codes the active model in the
4
+ footer, so you can tell at a glance which model family is in use.
5
+
6
+ It replaces the plain `claude-opus-4-8 • medium` text in the bottom-right of
7
+ pi's footer with a background-colored badge. Everything else the default
8
+ footer shows (cwd, git branch, token stats, context %, and other extensions'
9
+ status line) is faithfully reproduced.
10
+
11
+ ## Colors
12
+
13
+ Model families are matched by substring against the model id and painted with
14
+ strongly distinct hues:
15
+
16
+ | Family | Color |
17
+ | ------------------------- | ---------------- |
18
+ | `opus` | deep purple |
19
+ | `sonnet` | bright cyan-blue |
20
+ | `fable` | vivid orange |
21
+ | `haiku` | green |
22
+ | `gpt`, `o1`, `o3`, `o4` | OpenAI teal |
23
+ | `gemini` | Google blue |
24
+ | `grok` | slate |
25
+ | anything else | gray (fallback) |
26
+
27
+ The foreground color (black or white) is chosen automatically from the
28
+ background's luminance for readability. The badge follows model changes made
29
+ via `/model` or `Ctrl+P`.
30
+
31
+ ## Install
32
+
33
+ ```bash
34
+ pi install npm:@yusukeshib/pi-colored-model-status
35
+ ```
36
+
37
+ Then reload (or restart pi):
38
+
39
+ ```
40
+ /reload
41
+ ```
42
+
43
+ To try it for a single run without installing:
44
+
45
+ ```bash
46
+ pi -e npm:@yusukeshib/pi-colored-model-status
47
+ ```
48
+
49
+ ## Customize
50
+
51
+ Edit the `BADGES` array at the top of
52
+ [`extensions/colored-model-status.ts`](extensions/colored-model-status.ts).
53
+ Each entry maps substring keywords to an RGB background (and an optional `fg`):
54
+
55
+ ```ts
56
+ const BADGES = [
57
+ { match: ["opus"], bg: [147, 51, 234] },
58
+ { match: ["sonnet"], bg: [14, 165, 233] },
59
+ { match: ["fable"], bg: [234, 88, 12] },
60
+ // …add your own, e.g.:
61
+ { match: ["deepseek"], bg: [30, 90, 200] },
62
+ ];
63
+ ```
64
+
65
+ Matching is a case-insensitive substring test against the model id, so `gpt`
66
+ matches `gpt-4o`, `o3` matches `o3-mini`, and so on. The first matching entry
67
+ wins.
68
+
69
+ ## How it works
70
+
71
+ `theme.bg()` only accepts theme tokens, so to render arbitrary,
72
+ model-specific colors the extension emits raw SGR truecolor escapes
73
+ (`48;2;R;G;B`) directly. It uses `ctx.ui.setFooter()` to take over the footer
74
+ and re-implements pi's default footer layout, coloring only the model +
75
+ thinking-level segment.
76
+
77
+ Because it re-creates the footer, it mirrors pi's default footer rendering as
78
+ of the pinned `@earendil-works/pi-coding-agent` version. If pi's footer layout
79
+ changes upstream, this extension may need updating to match.
80
+
81
+ ### Note on the `(auto)` context indicator
82
+
83
+ pi's default footer appends `(auto)` to the context-usage figure when
84
+ auto-compaction is enabled. That state is not exposed to extensions, so this
85
+ reimplementation leaves it blank. If you rely on that indicator, adjust
86
+ `autoInd` in the source.
87
+
88
+ ## Requirements
89
+
90
+ Requires a truecolor-capable terminal (most modern terminals: iTerm2, Kitty,
91
+ WezTerm, Ghostty, Windows Terminal, VS Code). Check with:
92
+
93
+ ```bash
94
+ echo $COLORTERM # "truecolor" or "24bit"
95
+ ```
96
+
97
+ ## License
98
+
99
+ MIT © Yusuke Shibata
@@ -0,0 +1,216 @@
1
+ /**
2
+ * colored-model-status — color-code the active model in pi's footer.
3
+ *
4
+ * Replaces the plain `claude-opus-4-8 • medium` text in the bottom-right of
5
+ * pi's footer with a background-colored badge, so you can tell at a glance
6
+ * which model family is active. The footer is fully re-created via
7
+ * `setFooter`, faithfully reproducing the default footer (cwd, git branch,
8
+ * token stats, context %, extension statuses) while painting only the
9
+ * model + thinking segment.
10
+ *
11
+ * `theme.bg()` only accepts theme tokens, so to get arbitrary, model-specific
12
+ * colors we emit raw SGR truecolor escapes (48;2;R;G;B) directly.
13
+ */
14
+
15
+ import { isAbsolute, relative, resolve, sep } from "node:path";
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
18
+
19
+ type Rgb = readonly [number, number, number];
20
+
21
+ interface Badge {
22
+ /** Substring keywords matched against the lower-cased model id. */
23
+ match: readonly string[];
24
+ bg: Rgb;
25
+ /** Foreground color. Defaults to black/white chosen from background luminance. */
26
+ fg?: Rgb;
27
+ }
28
+
29
+ // Matched top-to-bottom; first hit wins. Hues are spread far apart per family.
30
+ const BADGES: readonly Badge[] = [
31
+ { match: ["opus"], bg: [147, 51, 234] }, // deep purple
32
+ { match: ["sonnet"], bg: [14, 165, 233] }, // bright cyan-blue
33
+ { match: ["fable"], bg: [234, 88, 12] }, // vivid orange
34
+ { match: ["haiku"], bg: [22, 163, 74] }, // green
35
+ { match: ["gpt", "o1", "o3", "o4"], bg: [16, 163, 127] }, // OpenAI teal
36
+ { match: ["gemini"], bg: [66, 133, 244] }, // Google blue
37
+ { match: ["grok"], bg: [30, 41, 59] }, // slate
38
+ ];
39
+
40
+ const FALLBACK: Badge = { match: [], bg: [82, 82, 91] }; // gray
41
+
42
+ function pickBadge(id: string): Badge {
43
+ const lower = id.toLowerCase();
44
+ return BADGES.find((b) => b.match.some((k) => lower.includes(k))) ?? FALLBACK;
45
+ }
46
+
47
+ /** Pick a contrasting foreground (black/white) from the background luminance. */
48
+ function autoFg([r, g, b]: Rgb): Rgb {
49
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
50
+ return luminance > 0.6 ? [0, 0, 0] : [255, 255, 255];
51
+ }
52
+
53
+ /** Paint text as a background-colored badge (padded with spaces for a clear block). */
54
+ function paintBadge(text: string, badge: Badge): string {
55
+ const [br, bg, bb] = badge.bg;
56
+ const [fr, fg, fb] = badge.fg ?? autoFg(badge.bg);
57
+ const bgSeq = `\x1b[48;2;${br};${bg};${bb}m`;
58
+ const fgSeq = `\x1b[38;2;${fr};${fg};${fb}m`;
59
+ return `${bgSeq}${fgSeq} ${text} \x1b[0m`;
60
+ }
61
+
62
+ function formatTokens(count: number): string {
63
+ if (count < 1000) return count.toString();
64
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
65
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
66
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
67
+ return `${Math.round(count / 1000000)}M`;
68
+ }
69
+
70
+ function formatCwd(cwd: string, home: string | undefined): string {
71
+ if (!home) return cwd;
72
+ const rc = resolve(cwd);
73
+ const rh = resolve(home);
74
+ const rel = relative(rh, rc);
75
+ const inside = rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
76
+ if (!inside) return cwd;
77
+ return rel === "" ? "~" : `~${sep}${rel}`;
78
+ }
79
+
80
+ function sanitizeStatus(text: string): string {
81
+ return text
82
+ .replace(/[\r\n\t]/g, " ")
83
+ .replace(/ +/g, " ")
84
+ .trim();
85
+ }
86
+
87
+ export default function (pi: ExtensionAPI) {
88
+ pi.on("session_start", (_event, ctx) => {
89
+ ctx.ui.setFooter((tui, theme, footerData) => {
90
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
91
+
92
+ return {
93
+ dispose: unsub,
94
+ invalidate() {},
95
+ render(width: number): string[] {
96
+ const sm = ctx.sessionManager;
97
+ const model = ctx.model;
98
+
99
+ // --- Aggregate token stats across all entries ---
100
+ let input = 0;
101
+ let output = 0;
102
+ let cacheRead = 0;
103
+ let cacheWrite = 0;
104
+ let cost = 0;
105
+ let latestCacheHitRate: number | undefined;
106
+ for (const e of sm.getEntries()) {
107
+ if (e.type === "message" && e.message.role === "assistant") {
108
+ const u = e.message.usage;
109
+ input += u.input;
110
+ output += u.output;
111
+ cacheRead += u.cacheRead;
112
+ cacheWrite += u.cacheWrite;
113
+ cost += u.cost.total;
114
+ const prompt = u.input + u.cacheRead + u.cacheWrite;
115
+ latestCacheHitRate = prompt > 0 ? (u.cacheRead / prompt) * 100 : undefined;
116
+ }
117
+ }
118
+
119
+ // --- Context usage ---
120
+ const usage = ctx.getContextUsage();
121
+ const contextWindow = usage?.contextWindow ?? model?.contextWindow ?? 0;
122
+ const pctValue = usage?.percent ?? 0;
123
+ const pct = usage?.percent != null ? pctValue.toFixed(1) : "?";
124
+
125
+ // --- pwd line (+ git branch + session name) ---
126
+ let pwd = formatCwd(sm.getCwd(), process.env.HOME || process.env.USERPROFILE);
127
+ const branch = footerData.getGitBranch();
128
+ if (branch) pwd = `${pwd} (${branch})`;
129
+ const sessionName = sm.getSessionName();
130
+ if (sessionName) pwd = `${pwd} • ${sessionName}`;
131
+
132
+ // --- stats (left side) ---
133
+ const parts: string[] = [];
134
+ if (input) parts.push(`↑${formatTokens(input)}`);
135
+ if (output) parts.push(`↓${formatTokens(output)}`);
136
+ if (cacheRead) parts.push(`R${formatTokens(cacheRead)}`);
137
+ if (cacheWrite) parts.push(`W${formatTokens(cacheWrite)}`);
138
+ if ((cacheRead > 0 || cacheWrite > 0) && latestCacheHitRate !== undefined) {
139
+ parts.push(`CH${latestCacheHitRate.toFixed(1)}%`);
140
+ }
141
+ const usingSub = model ? ctx.modelRegistry.isUsingOAuth(model) : false;
142
+ if (cost || usingSub) parts.push(`$${cost.toFixed(3)}${usingSub ? " (sub)" : ""}`);
143
+
144
+ const autoInd = ""; // auto-compact indicator (kept blank; see note in README)
145
+ const pctDisplay =
146
+ pct === "?"
147
+ ? `?/${formatTokens(contextWindow)}${autoInd}`
148
+ : `${pct}%/${formatTokens(contextWindow)}${autoInd}`;
149
+ let pctStr: string;
150
+ if (pctValue > 90) pctStr = theme.fg("error", pctDisplay);
151
+ else if (pctValue > 70) pctStr = theme.fg("warning", pctDisplay);
152
+ else pctStr = pctDisplay;
153
+ parts.push(pctStr);
154
+
155
+ let statsLeft = parts.join(" ");
156
+ let statsLeftWidth = visibleWidth(statsLeft);
157
+ if (statsLeftWidth > width) {
158
+ statsLeft = truncateToWidth(statsLeft, width, "...");
159
+ statsLeftWidth = visibleWidth(statsLeft);
160
+ }
161
+
162
+ // --- Right side: model + thinking as a colored badge ---
163
+ const modelName = model?.id || "no-model";
164
+ let rightText = modelName;
165
+ if (model?.reasoning) {
166
+ const level = pi.getThinkingLevel() || "off";
167
+ rightText = level === "off" ? `${modelName} • thinking off` : `${modelName} • ${level}`;
168
+ }
169
+ // Prefix (provider) when more than one provider is available
170
+ if (footerData.getAvailableProviderCount() > 1 && model) {
171
+ rightText = `(${model.provider}) ${rightText}`;
172
+ }
173
+
174
+ const badge = pickBadge(modelName);
175
+ // Badge adds a leading/trailing space, so width = text width + 2
176
+ const rightWidth = visibleWidth(rightText) + 2;
177
+
178
+ const minPadding = 2;
179
+ const dimStatsLeft = theme.fg("dim", statsLeft);
180
+ let statsLine: string;
181
+ if (statsLeftWidth + minPadding + rightWidth <= width) {
182
+ const pad = " ".repeat(width - statsLeftWidth - rightWidth);
183
+ statsLine = dimStatsLeft + theme.fg("dim", pad) + paintBadge(rightText, badge);
184
+ } else {
185
+ // Right side does not fit: truncate text, then paint
186
+ const avail = width - statsLeftWidth - minPadding - 2; // -2 for the badge's padding spaces
187
+ if (avail > 0) {
188
+ const truncated = truncateToWidth(rightText, avail, "");
189
+ const painted = paintBadge(truncated, badge);
190
+ const pad = " ".repeat(
191
+ Math.max(0, width - statsLeftWidth - (visibleWidth(truncated) + 2)),
192
+ );
193
+ statsLine = dimStatsLeft + theme.fg("dim", pad) + painted;
194
+ } else {
195
+ statsLine = dimStatsLeft;
196
+ }
197
+ }
198
+
199
+ const pwdLine = truncateToWidth(theme.fg("dim", pwd), width, theme.fg("dim", "..."));
200
+ const lines = [pwdLine, statsLine];
201
+
202
+ // --- Extension status line (token-counter / working-status, etc.) ---
203
+ const statuses = footerData.getExtensionStatuses();
204
+ if (statuses.size > 0) {
205
+ const sorted = Array.from(statuses.entries())
206
+ .sort(([a], [b]) => a.localeCompare(b))
207
+ .map(([, t]) => sanitizeStatus(t));
208
+ lines.push(truncateToWidth(sorted.join(" "), width, theme.fg("dim", "...")));
209
+ }
210
+
211
+ return lines;
212
+ },
213
+ };
214
+ });
215
+ });
216
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@yusukeshib/pi-colored-model-status",
3
+ "version": "0.1.0",
4
+ "description": "Color-code the active model in pi's footer so you can tell at a glance which model family (opus, sonnet, fable, gpt, gemini, …) is in use.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "footer",
9
+ "model",
10
+ "status"
11
+ ],
12
+ "type": "module",
13
+ "license": "MIT",
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "author": "Yusuke Shibata <yusukeshib@gmail.com>",
18
+ "homepage": "https://github.com/yusukeshib/pi-colored-model-status#readme",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/yusukeshib/pi-colored-model-status.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/yusukeshib/pi-colored-model-status/issues"
25
+ },
26
+ "files": [
27
+ "extensions/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "check": "bun run typecheck && bun run biome:check",
34
+ "release": "bun publish --access public",
35
+ "lint": "biome lint .",
36
+ "format": "biome format --write .",
37
+ "format:check": "biome format .",
38
+ "biome:check": "biome check ."
39
+ },
40
+ "devDependencies": {
41
+ "@biomejs/biome": "^2.4.14",
42
+ "@earendil-works/pi-coding-agent": "^0.80.0",
43
+ "@earendil-works/pi-tui": "*",
44
+ "@types/bun": "^1.3.0",
45
+ "typescript": "^5.7.0"
46
+ },
47
+ "peerDependencies": {
48
+ "@earendil-works/pi-coding-agent": "*",
49
+ "@earendil-works/pi-tui": "*"
50
+ },
51
+ "pi": {
52
+ "extensions": [
53
+ "./extensions"
54
+ ]
55
+ }
56
+ }