@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 +21 -0
- package/README.md +99 -0
- package/extensions/colored-model-status.ts +216 -0
- package/package.json +56 -0
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
|
+
}
|