@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.
Files changed (2) hide show
  1. package/index.ts +63 -138
  2. 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
- * 当模型工作时(processing):
8
- * - 输入框上方显示一条脉冲发光带(霓虹色循环)
9
- * - Footer 中显示脉冲指示点 ● 代替思考等级文字
10
- * - 没有任何 "working..." 文字
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 PULSE_PALETTE = [
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
- export default function (pi: ExtensionAPI) {
70
- let thinkingLevel = "off";
71
- let working = false; // 模型是否正在工作
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
- function startPulse(rt: () => void) {
81
- if (pulseTimer) return;
82
- pulseTimer = setInterval(() => {
83
- pulsePhase = (pulsePhase + 1) % PULSE_PALETTE.length;
84
- rt();
85
- }, PULSE_INTERVAL_MS);
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
- function stopPulse() {
89
- if (pulseTimer) {
90
- clearInterval(pulseTimer);
91
- pulseTimer = null;
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
- pi.on("session_start", async (_event, ctx) => {
96
- thinkingLevel = pi.getThinkingLevel();
100
+ // ═══════════════════════════════════════════════════════════════════════════
101
+ // Extension
102
+ // ═══════════════════════════════════════════════════════════════════════════
97
103
 
98
- // ── 脉冲发光 Widget(输入框上方) ──
99
- ctx.ui.setWidget("nano-glow", (tui) => {
100
- startPulse(() => tui.requestRender());
104
+ export default function (pi: ExtensionAPI) {
105
+ let thinkingLevel = "off";
106
+ let requestRender: (() => void) | undefined;
107
+ const refresh = () => requestRender?.();
101
108
 
102
- return {
103
- dispose() { stopPulse(); },
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(renderContextBar(ctx));
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
- /** 16 进制色值 → ANSI 24-bit true color 背景色转义序列 */
210
- function ansiBg(hex: string, text: string): string {
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.2.0",
4
- "description": "超轻量 powerline 风格 footer for Pi Coding Agent — 霓虹配色,脉冲发光指示,实时显示模型、目录、上下文、token 和费用",
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",