@lokiyou/pi-nano-footer 0.2.0 → 0.4.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/index.ts +63 -138
- package/package.json +2 -2
package/index.ts
CHANGED
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* pi-nano-footer — 超轻量 powerline 风格 footer +
|
|
2
|
+
* pi-nano-footer — 超轻量 powerline 风格 footer + 输入框呼吸发光
|
|
3
3
|
*
|
|
4
4
|
* 精确复刻 pi-powerline-footer default 预设的样式 +
|
|
5
5
|
* 用户自定义霓虹色配色方案。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
12
|
-
* 安装:放到 ~/.pi/agent/extensions/ 后重启 pi 即可自动加载。
|
|
13
|
-
* 临时测试:pi -e ~/.pi/agent/extensions/pi-nano-footer.ts
|
|
7
|
+
* 当模型工作时:
|
|
8
|
+
* - 输入框边框基于当前颜色做呼吸发光(明暗渐变)
|
|
9
|
+
* - "Working..." 保持原样
|
|
10
|
+
* - Footer 不变
|
|
14
11
|
*/
|
|
15
12
|
|
|
16
13
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
17
14
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Editor } from "@earendil-works/pi-tui";
|
|
18
16
|
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
19
17
|
|
|
20
18
|
// ── Nerd Font 图标(与 pi-powerline-footer 完全一致) ──
|
|
@@ -48,17 +46,8 @@ const C = {
|
|
|
48
46
|
border: "#dfe6e9",
|
|
49
47
|
};
|
|
50
48
|
|
|
51
|
-
// ──
|
|
52
|
-
const
|
|
53
|
-
"#ff3cac", // 热粉
|
|
54
|
-
"#ff6b9d", // 粉红
|
|
55
|
-
"#ff9f1c", // 橙
|
|
56
|
-
"#fdcb6e", // 黄
|
|
57
|
-
"#00ff87", // 荧光绿
|
|
58
|
-
"#00d4ff", // 青蓝
|
|
59
|
-
"#a855f7", // 紫
|
|
60
|
-
"#6c5ce7", // 紫蓝
|
|
61
|
-
];
|
|
49
|
+
// ── ANSI 24-bit true color 正则(用于从 borderColor 函数提取颜色) ──
|
|
50
|
+
const ANSI_RE = /\x1b\[38;2;(\d+);(\d+);(\d+)m/;
|
|
62
51
|
|
|
63
52
|
// ── Rainbow 色表(high / xhigh 思考等级用) ──
|
|
64
53
|
const RAINBOW = [
|
|
@@ -66,63 +55,67 @@ const RAINBOW = [
|
|
|
66
55
|
"#89d281", "#00afaf", "#178fb9", "#b281d6",
|
|
67
56
|
];
|
|
68
57
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let pulsePhase = 0; // 脉冲动画相位
|
|
73
|
-
let requestRender: (() => void) | undefined;
|
|
74
|
-
const refresh = () => requestRender?.();
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
// 输入框呼吸发光(基于当前边框颜色做明暗呼吸)
|
|
60
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
75
61
|
|
|
76
|
-
|
|
77
|
-
const PULSE_INTERVAL_MS = 100;
|
|
78
|
-
let pulseTimer: ReturnType<typeof setInterval> | null = null;
|
|
62
|
+
let working = false;
|
|
79
63
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
64
|
+
/**
|
|
65
|
+
* 从 borderColor 函数采样一个字符,提取其中使用的 RGB 色值
|
|
66
|
+
*/
|
|
67
|
+
function extractRGB(fn: (str: string) => string): [number, number, number] | null {
|
|
68
|
+
const sample = fn("─");
|
|
69
|
+
// borderColor 输出格式:\x1b[38;2;R;G;Bm─\x1b[0m
|
|
70
|
+
const m = sample.match(ANSI_RE);
|
|
71
|
+
if (!m) return null;
|
|
72
|
+
return [parseInt(m[1]), parseInt(m[2]), parseInt(m[3])];
|
|
73
|
+
}
|
|
87
74
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
75
|
+
/** 保存原始 render */
|
|
76
|
+
const origRender = Editor.prototype.render;
|
|
77
|
+
|
|
78
|
+
Editor.prototype.render = function (width: number): string[] {
|
|
79
|
+
if (working) {
|
|
80
|
+
const saved = this.borderColor;
|
|
81
|
+
const rgb = extractRGB(saved);
|
|
82
|
+
if (rgb) {
|
|
83
|
+
const [r, g, b] = rgb;
|
|
84
|
+
// 呼吸亮度:sin 周期 ~800ms,亮度范围 40%~100%
|
|
85
|
+
// 用 performance.now() 保证时间连续的呼吸曲线
|
|
86
|
+
const t = 0.5 + 0.5 * Math.sin(performance.now() / 400);
|
|
87
|
+
const bright = 0.4 + 0.6 * t;
|
|
88
|
+
this.borderColor = (str: string) =>
|
|
89
|
+
`\x1b[38;2;${Math.round(r * bright)};${Math.round(g * bright)};${Math.round(b * bright)}m${str}\x1b[0m`;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return origRender.call(this, width);
|
|
93
|
+
} finally {
|
|
94
|
+
this.borderColor = saved;
|
|
92
95
|
}
|
|
93
96
|
}
|
|
97
|
+
return origRender.call(this, width);
|
|
98
|
+
};
|
|
94
99
|
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
101
|
+
// Extension
|
|
102
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
97
103
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
export default function (pi: ExtensionAPI) {
|
|
105
|
+
let thinkingLevel = "off";
|
|
106
|
+
let requestRender: (() => void) | undefined;
|
|
107
|
+
const refresh = () => requestRender?.();
|
|
101
108
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
invalidate() {},
|
|
105
|
-
render(width: number): string[] {
|
|
106
|
-
if (!working || width <= 0) return [];
|
|
107
|
-
return [renderGlowLine(width, pulsePhase)];
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
}, { placement: "aboveEditor" });
|
|
109
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
110
|
+
thinkingLevel = pi.getThinkingLevel();
|
|
111
111
|
|
|
112
|
-
// ── Footer ──
|
|
113
112
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
114
113
|
requestRender = () => tui.requestRender();
|
|
115
|
-
startPulse(() => tui.requestRender());
|
|
116
|
-
|
|
117
114
|
const unsubBranch = footerData.onBranchChange(() => tui.requestRender());
|
|
118
115
|
const clock = setInterval(() => tui.requestRender(), 30_000);
|
|
119
116
|
|
|
120
117
|
return {
|
|
121
|
-
dispose() {
|
|
122
|
-
unsubBranch();
|
|
123
|
-
clearInterval(clock);
|
|
124
|
-
stopPulse();
|
|
125
|
-
},
|
|
118
|
+
dispose() { unsubBranch(); clearInterval(clock); },
|
|
126
119
|
invalidate() {},
|
|
127
120
|
render(width: number): string[] {
|
|
128
121
|
if (width <= 0) return [];
|
|
@@ -134,20 +127,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
134
127
|
// 1. 模型 —— #ff3cac 热粉色
|
|
135
128
|
parts.push(ansi(C.model, `${icons.model} ${shorten(ctx.model?.id ?? "–")}`));
|
|
136
129
|
|
|
137
|
-
// 2.
|
|
138
|
-
|
|
139
|
-
if (working) {
|
|
140
|
-
parts.push(renderPulseDot(pulsePhase));
|
|
141
|
-
} else {
|
|
142
|
-
parts.push(renderThinkingLevel(thinkingLevel));
|
|
143
|
-
}
|
|
130
|
+
// 2. 思考等级 —— 按等级变色,high/xhigh 彩虹
|
|
131
|
+
parts.push(renderThinkingN(thinkingLevel));
|
|
144
132
|
|
|
145
133
|
// 3. 目录 basename —— #00d4ff 青蓝色
|
|
146
134
|
const dir = ctx.cwd.replace(/\\/g, "/").split("/").filter(Boolean).pop() || ctx.cwd;
|
|
147
135
|
parts.push(ansi(C.path, `${icons.folder} ${dir}`));
|
|
148
136
|
|
|
149
137
|
// 4. 上下文用量 —— #6c5ce7 紫 / #fdcb6e 黄 / #ff3366 红
|
|
150
|
-
parts.push(
|
|
138
|
+
parts.push(renderContextN(ctx));
|
|
151
139
|
|
|
152
140
|
// 5. Token 用量 —— #00ff87 荧光绿
|
|
153
141
|
const { input, cost } = calcTotals(ctx);
|
|
@@ -170,10 +158,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
170
158
|
});
|
|
171
159
|
pi.on("model_select", () => refresh());
|
|
172
160
|
|
|
173
|
-
// 工作状态切换
|
|
161
|
+
// 工作状态切换 → 控制脉冲发光
|
|
174
162
|
pi.on("agent_start", () => {
|
|
175
163
|
working = true;
|
|
176
|
-
pulsePhase = 0;
|
|
177
164
|
refresh();
|
|
178
165
|
});
|
|
179
166
|
pi.on("agent_end", () => {
|
|
@@ -181,20 +168,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
181
168
|
refresh();
|
|
182
169
|
});
|
|
183
170
|
|
|
184
|
-
// 传统事件:保持 footer 刷新
|
|
185
171
|
pi.on("turn_start", () => refresh());
|
|
186
172
|
pi.on("turn_end", () => refresh());
|
|
187
173
|
|
|
188
174
|
pi.on("session_shutdown", (_event, ctx) => {
|
|
189
175
|
ctx.ui.setFooter(undefined);
|
|
190
|
-
ctx.ui.setWidget("nano-glow", undefined);
|
|
191
|
-
stopPulse();
|
|
192
176
|
requestRender = undefined;
|
|
193
177
|
});
|
|
194
178
|
}
|
|
195
179
|
|
|
196
180
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
197
|
-
//
|
|
181
|
+
// Helper 函数
|
|
198
182
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
199
183
|
|
|
200
184
|
/** 16 进制色值 → ANSI 24-bit true color 前景色转义序列 */
|
|
@@ -206,62 +190,8 @@ function ansi(hex: string, text: string): string {
|
|
|
206
190
|
return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
207
191
|
}
|
|
208
192
|
|
|
209
|
-
/**
|
|
210
|
-
function
|
|
211
|
-
const h = hex.replace("#", "");
|
|
212
|
-
const r = parseInt(h.slice(0, 2), 16);
|
|
213
|
-
const g = parseInt(h.slice(2, 4), 16);
|
|
214
|
-
const b = parseInt(h.slice(4, 6), 16);
|
|
215
|
-
return `\x1b[48;2;${r};${g};${b}m${text}\x1b[0m`;
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** 基于亮度的半透明色(在黑色背景上混合) */
|
|
219
|
-
function alphaOnBlack(hex: string, alpha: number): string {
|
|
220
|
-
const h = hex.replace("#", "");
|
|
221
|
-
const r = Math.round(parseInt(h.slice(0, 2), 16) * alpha);
|
|
222
|
-
const g = Math.round(parseInt(h.slice(2, 4), 16) * alpha);
|
|
223
|
-
const b = Math.round(parseInt(h.slice(4, 6), 16) * alpha);
|
|
224
|
-
return `\x1b[48;2;${r};${g};${b}m \x1b[0m`;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── 脉冲发光带(输入框上方) ──
|
|
228
|
-
|
|
229
|
-
/** 渲染一条脉冲发光带,霓虹色从左到右波浪式前进 */
|
|
230
|
-
function renderGlowLine(width: number, phase: number): string {
|
|
231
|
-
const barWidth = Math.min(width, 60);
|
|
232
|
-
const offset = Math.floor((width - barWidth) / 2);
|
|
233
|
-
let line = "";
|
|
234
|
-
|
|
235
|
-
for (let i = 0; i < width; i++) {
|
|
236
|
-
if (i < offset || i >= offset + barWidth) {
|
|
237
|
-
line += " ";
|
|
238
|
-
} else {
|
|
239
|
-
const pos = (i - offset) / barWidth; // 0..1 位置
|
|
240
|
-
const colorIdx = (phase + Math.floor(pos * 4)) % PULSE_PALETTE.length;
|
|
241
|
-
// 边缘暗、中间亮 → 制作光晕效果
|
|
242
|
-
const edge = Math.sin(pos * Math.PI); // 0→1→0 正弦波
|
|
243
|
-
const alpha = 0.3 + 0.7 * edge; // 边缘 0.3,中间 1.0
|
|
244
|
-
const hex = PULSE_PALETTE[colorIdx].replace("#", "");
|
|
245
|
-
const r = Math.round(parseInt(hex.slice(0, 2), 16) * alpha);
|
|
246
|
-
const g = Math.round(parseInt(hex.slice(2, 4), 16) * alpha);
|
|
247
|
-
const b = Math.round(parseInt(hex.slice(4, 6), 16) * alpha);
|
|
248
|
-
line += `\x1b[48;2;${r};${g};${b}m \x1b[0m`; // 空格背景色 = 发光像素
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return line;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
// ── 脉冲指示点(Footer 中替代思考等级文字) ──
|
|
255
|
-
|
|
256
|
-
/** 渲染一个脉冲发光 ● 指示点 */
|
|
257
|
-
function renderPulseDot(phase: number): string {
|
|
258
|
-
const color = PULSE_PALETTE[phase % PULSE_PALETTE.length];
|
|
259
|
-
return ansi(color, "●");
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// ── 思考等级渲染 ──
|
|
263
|
-
|
|
264
|
-
function renderThinkingLevel(level: string): string {
|
|
193
|
+
/** 渲染思考等级 */
|
|
194
|
+
function renderThinkingN(level: string): string {
|
|
265
195
|
const label = level === "minimal" ? "min"
|
|
266
196
|
: level === "medium" ? "med"
|
|
267
197
|
: level;
|
|
@@ -282,9 +212,8 @@ function renderThinkingLevel(level: string): string {
|
|
|
282
212
|
}
|
|
283
213
|
}
|
|
284
214
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
function renderContextBar(ctx: any): string {
|
|
215
|
+
/** 渲染上下文用量,带百分比阈值颜色 */
|
|
216
|
+
function renderContextN(ctx: any): string {
|
|
288
217
|
const usage = ctx.getContextUsage();
|
|
289
218
|
const pct = usage?.percent;
|
|
290
219
|
const color = pct == null ? C.context
|
|
@@ -297,10 +226,6 @@ function renderContextBar(ctx: any): string {
|
|
|
297
226
|
return ansi(color, `${icons.context} ${pct?.toFixed(1) ?? "?"}%${maxStr}`);
|
|
298
227
|
}
|
|
299
228
|
|
|
300
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
301
|
-
// 工具函数
|
|
302
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
303
|
-
|
|
304
229
|
/** 遍历会话分支,累计 input token 数和总费用 */
|
|
305
230
|
function calcTotals(ctx: any): { input: number; cost: number } {
|
|
306
231
|
let input = 0, cost = 0;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lokiyou/pi-nano-footer",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "超轻量 powerline 风格 footer for Pi Coding Agent —
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "超轻量 powerline 风格 footer for Pi Coding Agent — 输入框脉冲发光,霓虹配色,实时显示模型、目录、上下文、token 和费用",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"pi-package",
|