@muzi3/pi-ui 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 +25 -0
- package/README.md +127 -0
- package/extensions/border-editor.ts +158 -0
- package/extensions/context-bar.ts +29 -0
- package/extensions/footer.ts +171 -0
- package/extensions/index.ts +211 -0
- package/extensions/indicator.ts +27 -0
- package/package.json +40 -0
- package/themes/pi-ui-catppuccin.json +96 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 muzi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
|
6
|
+
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
|
7
|
+
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
8
|
+
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
|
9
|
+
following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial
|
|
12
|
+
portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
|
15
|
+
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
|
16
|
+
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
|
17
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
|
18
|
+
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
The bundled theme `themes/pi-ui-mocha.json` is derived from the Catppuccin Mocha palette:
|
|
23
|
+
|
|
24
|
+
- Catppuccin — MIT, https://github.com/catppuccin/catppuccin
|
|
25
|
+
- Catppuccin color values as laid out by the pi theme "catppuccin-tui-mocha" — MIT
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @muzi3/pi-ui
|
|
2
|
+
|
|
3
|
+
Pi 扩展:把页脚换成四行上下文仪表盘,并在编辑器边框上常驻显示模型名与耗时,主题跟着当前主题走。
|
|
4
|
+
|
|
5
|
+
替代 `pi-catppuccin-tui` 的自实现版本,修掉了原实现的两处问题:指示器颜色和多处 token 写死了 Macchiato
|
|
6
|
+
的十六进制色(换主题后错色),以及自定义 footer 整体替换内置 footer 后、其他扩展的 `setStatus` 状态
|
|
7
|
+
(如 `@muzi3/comate-quota` 的额度)变得不可见。
|
|
8
|
+
|
|
9
|
+
## 能力
|
|
10
|
+
|
|
11
|
+
| 能力 | 说明 |
|
|
12
|
+
|------|------|
|
|
13
|
+
| `footer` | 四行仪表盘页脚,两条进度条置顶,替换内置 footer |
|
|
14
|
+
| `indicator` | 闪烁帧 `· • ● ● • ·` @120ms,颜色取自主题 token;关掉后不显示呼吸灯(内置指示器回退为 pi 默认) |
|
|
15
|
+
| `working` | 编辑器边框上常驻显示模型名与耗时,是否在生成只由呼吸灯表达 |
|
|
16
|
+
|
|
17
|
+
### footer 布局
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Comate ████░░░░░░░░░░ 25% / ¥50.00 main
|
|
21
|
+
Context ██░░░░░░░░░░░░ 1.7% / 272k ↑74.7k ↓3.8k $0.009
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
- 第 1 行:其他扩展通过 `setStatus` 设置的文本(如 `@muzi3/comate-quota` 的额度条)+ git 分支。
|
|
25
|
+
分支前是 Nerd Font 的 git 图标 `U+E0A0`(nf-pl-branch,与 starship 默认提示符同一字形,
|
|
26
|
+
`accent` 色),**需要 Nerd Font 字体**,否则显示为豆腐块;分支名本身为 `muted`
|
|
27
|
+
- 第 2 行:上下文进度条(14 格)+ `百分比 / 窗口` + 累计用量(`↑in ↓out $cost`)。
|
|
28
|
+
进度条配色按占用率分级:`<70%` 绿(`success`)、`70~90%` 黄(`warning`)、`>90%` 红(`error`);
|
|
29
|
+
刚压缩完占用未知时显示为暗色 `?`
|
|
30
|
+
- 第 1、2 行的标签列固定 8 列(`Comate ` / `Context `,含标签后的分隔空格),两条进度条因此左对齐。
|
|
31
|
+
`Context ` 由本包补齐;扩展状态行是别的包产出的文本,要自己把标签补齐到 8 列
|
|
32
|
+
(参考 `@muzi3/comate-quota`)——这是跨包视觉契约
|
|
33
|
+
- 行尾的分支与累计用量是次要信息,放不下就整段丢弃(不截断),进度条与百分比不参与降级
|
|
34
|
+
- footer 高度不超过 2 行:没装扩展就没有额度条,不在 git 仓库就没有分支
|
|
35
|
+
- 内核 footer 右侧的“模型名 • 思考等级”两项都不要:模型名在编辑器边框上常驻,思考等级由边框
|
|
36
|
+
颜色区分(每个等级一个 `thinking*` token,主题里已拉开层次)
|
|
37
|
+
- 累计用量口径与内置 footer 一致:assistant、带 usage 的 toolResult、branch_summary/compaction 求和
|
|
38
|
+
|
|
39
|
+
### 编辑器边框标题
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
── claude-sonnet-4-5 ⏱ 12s ──────────────────────────────── 空闲
|
|
43
|
+
── ● claude-sonnet-4-5 ⏱ 12s ────────────────────────────── 流式
|
|
44
|
+
>
|
|
45
|
+
────────────────────────────────────────────────────────────────────────
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- 用自定义编辑器(`PiUiBorderEditor extends CustomEditor`)接管编辑器上边框。pi 自带的
|
|
49
|
+
`WorkingStatusIndicator` 只在流式期间挂在边框上(`turn_start` 挂、`turn_end` 清),空闲即消失,
|
|
50
|
+
承载不了“常驻”;所以子类覆写 `setWorkingStatusIndicator`,忽略 pi 的挂/清,恒挂自己的标题。
|
|
51
|
+
边框拼接、截断、`↑ N more` 滚动提示全部沿用 `CustomEditor.renderTopBorder`,没有重写排版
|
|
52
|
+
- 输入被滚动时不用取舍:`↑ N more` 会与标题并列出现在同一行边框上
|
|
53
|
+
(`── ● model ⏱ 12s ──── ↑ 5 more ────`),这是继承来的行为,不是额外实现
|
|
54
|
+
- 呼吸灯:流式为 `· • ● ● • ·` @120ms(颜色按主题 token 递进)。不闪的时候完全不显示——空闲、
|
|
55
|
+
或关掉 `indicator` 时都不留静止的点(静止的点反而更难看出在不在生成);代价是模型名会左右
|
|
56
|
+
移 2 列,在以 `─` 为主的边框上几乎看不出来
|
|
57
|
+
- 耗时口径与旧版 footer 一致,只是位置变了:流式为已进行秒数(随呼吸灯帧重绘跳秒),
|
|
58
|
+
空闲为上一轮总耗时;宽度不足时先丢掉 `⏱ 耗时`,再逐字截断模型名
|
|
59
|
+
- 退出/关闭 `working` 会换回内置编辑器,还原 pi 默认:仅流式期间在边框显示模型名
|
|
60
|
+
- 需要 pi `>= 0.85`(`CustomEditor` 的 `embedWorkingStatus` 与 `setWorkingStatusIndicator` 从该版本才有);
|
|
61
|
+
本包不依赖 `@earendil-works/pi-coding-agent` 的本地副本,运行时由 pi 的扩展加载器别名到自身
|
|
62
|
+
|
|
63
|
+
### 模型名覆盖(跨扩展)
|
|
64
|
+
|
|
65
|
+
边框标题默认显示当前会话模型,其他扩展可以在自己占用会话期间临时把它换掉:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
pi.events.emit("pi-ui:model-override", "glm-4.6"); // 覆盖
|
|
69
|
+
pi.events.emit("pi-ui:model-override", undefined); // 恢复会话模型
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
通道是软约定:没扩展发布时什么都不发生,空串与 `undefined` 都表示恢复,
|
|
73
|
+
`session_start`/`session_shutdown` 会清空残留值。`@muzi3/compaction-custom-model`
|
|
74
|
+
靠它在压缩期间显示压缩所用模型。
|
|
75
|
+
|
|
76
|
+
### 宽度不足时的降级顺序
|
|
77
|
+
|
|
78
|
+
自定义 footer 会整体接管内置 footer,内置第三行(扩展状态)随之消失,所以扩展状态置于页脚最前。
|
|
79
|
+
两行各自独立降级,每行都保住它最有信息量的部分:
|
|
80
|
+
1. 第 1 行:完整(额度条 + 分支)→ 丢分支 → 额度条截断加省略号
|
|
81
|
+
2. 第 2 行:完整(进度条 + 计数 + 累计用量)→ 丢累计用量 → 丢 `Context ` 标签 → 丢百分比/窗口
|
|
82
|
+
→ 只剩进度条;进度条不接受被截断
|
|
83
|
+
|
|
84
|
+
## 命令
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
/pi-ui # 查看三块能力的开关状态
|
|
88
|
+
/pi-ui footer|indicator|working on|off # 单独开关
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
关闭 `footer` 会恢复 pi 内置 footer;关闭 `working` 会换回内置编辑器(流式期间才显示模型名);关闭
|
|
92
|
+
`indicator` 会让呼吸灯不再显示。开关只在本次会话内有效,不写盘。
|
|
93
|
+
|
|
94
|
+
## 主题
|
|
95
|
+
|
|
96
|
+
`themes/pi-ui-catppuccin.json`(主题名 `pi-ui-catppuccin`)在 Catppuccin Mocha 基础上只改层次关系,
|
|
97
|
+
色板本身不变:
|
|
98
|
+
|
|
99
|
+
| token | 原值 | 现值 | 原因 |
|
|
100
|
+
|-------|------|------|------|
|
|
101
|
+
| `toolErrorBg` | `surface0` | `redBg` `#493446` | 原来与 `toolSuccessBg` 同为 `surface0`,成功/失败的工具块看不出区别。Catppuccin 没有暗红底色,`#493446` 是在 `base` 上叠 20% `red` 得到的 |
|
|
102
|
+
| `selectedBg` | `surface0` | `surface1` | 原来与 `userMessageBg` 同色,选中行在列表里几乎不可见 |
|
|
103
|
+
| `searchMatchBg` | 未定义(回落 `selectedBg`) | `surface2` | 搜索命中需要比选中行更强的对比 |
|
|
104
|
+
| `searchMatchText` | 未定义(回落 `text`) | `text` | 显式写出,避免跟随 `selectedBg` 改动漂移 |
|
|
105
|
+
| `scrollbarTrack` | 未定义(回落 `muted`) | `surface0` | 全屏模式下滚动条轨道应当几乎不可见 |
|
|
106
|
+
| `scrollbarThumb` | 未定义(回落 `text`) | `overlay0` | 滑块比轨道亮,但不该抢正文 |
|
|
107
|
+
| `mdCodeBlockBorder` | `surface2` | `surface1` | 与主 `border` 同级时,代码块外框噪音过大 |
|
|
108
|
+
| `mdQuoteBorder` | `surface2` | `surface1` | 同上,引用属于次要结构 |
|
|
109
|
+
| `mdHeading` | `mauve` | `lavender` | `mauve` 同时是 `accent`/`mdListBullet`/`syntaxKeyword`/`thinkingHigh`,标题再占用就分不出主次 |
|
|
110
|
+
| `mdListBullet` | `mauve` | `overlay2` | 列表符号属于次要装饰,降为灰色 |
|
|
111
|
+
| `thinkingXhigh` | `red` | `maroon` | 给 `thinkingMax` 让出 `red`,形成 `mauve → maroon → red` 的递进 |
|
|
112
|
+
| `thinkingMax` | 未定义 | `red` | 补上最高档,取代回落的默认色 |
|
|
113
|
+
|
|
114
|
+
色板与配色思路来自 Catppuccin(MIT)与 pi 主题 `catppuccin-tui-mocha`(MIT)。
|
|
115
|
+
|
|
116
|
+
## 安装
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
pi install npm:@muzi3/pi-ui
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
从 `pi-catppuccin-tui` 迁移时,先装本包、把 `~/.pi/agent/settings.json` 的 `theme` 改成 `pi-ui-catppuccin`,
|
|
123
|
+
再卸载前者。两个包都会调用 `setFooter`,后加载者生效,顺序不确定;先卸载可避免页脚互相覆盖。
|
|
124
|
+
|
|
125
|
+
## 敏感信息
|
|
126
|
+
|
|
127
|
+
本包不含个人信息、内部工具名与任何凭证。发布前扫描见仓库根 `.pi/RULES.md`。
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
|
|
4
|
+
import { buildPulseFrames, PULSE_INTERVAL_MS } from "./indicator.ts";
|
|
5
|
+
|
|
6
|
+
export function formatDuration(seconds: number): string {
|
|
7
|
+
const total = Math.max(0, Math.floor(seconds));
|
|
8
|
+
if (total < 60) return `${total}s`;
|
|
9
|
+
return `${Math.floor(total / 60)}m${total % 60}s`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 边框标题的数据来源,由入口注入,使组件不依赖 ExtensionContext。 */
|
|
13
|
+
export type EditorTitleInputs = {
|
|
14
|
+
/** 边框上显示的模型名;调用方可以整体替换(如压缩期间换成压缩所用模型)。 */
|
|
15
|
+
getModelId(): string | undefined;
|
|
16
|
+
/** 流式中为已进行秒数,空闲时为上一轮耗时秒数,无数据为 undefined。 */
|
|
17
|
+
getTimerSeconds(): number | undefined;
|
|
18
|
+
isStreaming(): boolean;
|
|
19
|
+
/** 关掉后不显示呼吸灯(而不是停在静止帧)。 */
|
|
20
|
+
isPulseEnabled(): boolean;
|
|
21
|
+
/** 编辑器主题(getEditorTheme)只有 borderColor/selectList,配色要另取实时主题。 */
|
|
22
|
+
getTheme(): Theme;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* CustomEditor.renderTopBorder 只调用这两个方法,所以标题不必是 pi 的
|
|
27
|
+
* WorkingStatusIndicator 类型,只要有同样的形状即可。
|
|
28
|
+
*/
|
|
29
|
+
type BorderTitle = {
|
|
30
|
+
renderInBorder(width: number): string;
|
|
31
|
+
renderSpinnerInBorder(width: number): string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 编辑器边框上的常驻标题:`<呼吸灯> <模型名> ⏱ 耗时`,空闲时也在(呼吸灯不闪则不显示)。
|
|
36
|
+
*
|
|
37
|
+
* 帧由自己按时间算,流式时用定时器驱动重绘(pi 的 WorkingStatusIndicator 靠 Loader 同理)。
|
|
38
|
+
*/
|
|
39
|
+
class PiUiBorderTitle implements BorderTitle {
|
|
40
|
+
private readonly tui: TUI;
|
|
41
|
+
private readonly inputs: EditorTitleInputs;
|
|
42
|
+
private ticker: ReturnType<typeof setInterval> | undefined;
|
|
43
|
+
private framesKey: string | undefined;
|
|
44
|
+
private frames: string[] = [];
|
|
45
|
+
|
|
46
|
+
constructor(tui: TUI, inputs: EditorTitleInputs) {
|
|
47
|
+
this.tui = tui;
|
|
48
|
+
this.inputs = inputs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 状态或模型变化后调用:立即重绘,并按是否流式启停呼吸灯。 */
|
|
52
|
+
sync(): void {
|
|
53
|
+
if (this.inputs.isStreaming()) {
|
|
54
|
+
if (!this.ticker) {
|
|
55
|
+
this.ticker = setInterval(() => this.tui.requestRender(), PULSE_INTERVAL_MS);
|
|
56
|
+
}
|
|
57
|
+
} else {
|
|
58
|
+
this.stop();
|
|
59
|
+
}
|
|
60
|
+
this.tui.requestRender();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
dispose(): void {
|
|
64
|
+
this.stop();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 标题主体。与边框的拼接、截断由外层 renderTopBorder 负责,这里只要不超过 width。 */
|
|
68
|
+
renderInBorder(width: number): string {
|
|
69
|
+
const theme = this.inputs.getTheme();
|
|
70
|
+
const model = this.inputs.getModelId();
|
|
71
|
+
const pulse = this.renderPulse(theme);
|
|
72
|
+
|
|
73
|
+
// 不闪的时候不占位:留一个静止的点比没有更难看出在不在生成。
|
|
74
|
+
const parts: string[] = [];
|
|
75
|
+
if (pulse !== undefined) parts.push(pulse);
|
|
76
|
+
if (model !== undefined) parts.push(theme.fg("text", model));
|
|
77
|
+
const head = parts.join(" ");
|
|
78
|
+
|
|
79
|
+
const seconds = this.inputs.getTimerSeconds();
|
|
80
|
+
// 耗时是给模型名做注脚的:没有标题就不单独出现(否则会多出空档)。
|
|
81
|
+
if (head !== "" && seconds !== undefined) {
|
|
82
|
+
// 宽度不足时先丢耗时:模型名是这一行的目的,耗时是附带的。
|
|
83
|
+
const timer = theme.fg("dim", `⏱ ${formatDuration(seconds)}`);
|
|
84
|
+
if (visibleWidth(head) + 2 + visibleWidth(timer) <= width) return `${head} ${timer}`;
|
|
85
|
+
}
|
|
86
|
+
return truncateToWidth(head, width, "");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 边框窄到放不下标题时只留呼吸灯;不闪时交给外层画普通边框。 */
|
|
90
|
+
renderSpinnerInBorder(width: number): string {
|
|
91
|
+
const pulse = this.renderPulse(this.inputs.getTheme());
|
|
92
|
+
return pulse === undefined ? "" : truncateToWidth(pulse, width, "");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private stop(): void {
|
|
96
|
+
if (this.ticker) {
|
|
97
|
+
clearInterval(this.ticker);
|
|
98
|
+
this.ticker = undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 帧是构建期快照(theme.fg 在此刻求值),这里按主题名缓存,换主题自动重建。 */
|
|
103
|
+
private currentFrames(theme: Theme): string[] {
|
|
104
|
+
if (theme.name !== this.framesKey) {
|
|
105
|
+
this.framesKey = theme.name;
|
|
106
|
+
this.frames = buildPulseFrames(theme);
|
|
107
|
+
}
|
|
108
|
+
return this.frames;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 流式时按时间取帧(与定时器同频,无需自增索引);不闪时返回 undefined,即完全不显示。 */
|
|
112
|
+
private renderPulse(theme: Theme): string | undefined {
|
|
113
|
+
if (!this.inputs.isPulseEnabled() || !this.inputs.isStreaming()) return undefined;
|
|
114
|
+
const frames = this.currentFrames(theme);
|
|
115
|
+
return frames[Math.floor(Date.now() / PULSE_INTERVAL_MS) % frames.length];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
type EditorArgs = ConstructorParameters<typeof CustomEditor>;
|
|
120
|
+
type IndicatorSlot = Parameters<CustomEditor["setWorkingStatusIndicator"]>[0];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 把模型名与耗时做成编辑器边框上的常驻标题。
|
|
124
|
+
*
|
|
125
|
+
* pi 的 WorkingStatusIndicator 只在流式期间挂在边框上(turn_start 挂、turn_end 清),
|
|
126
|
+
* 空闲即消失,承载不了"常驻"。这里覆写 setWorkingStatusIndicator,忽略 pi 的挂/清,
|
|
127
|
+
* 恒挂自己的标题;边框拼接、截断、多行溢出标签全部沿用 CustomEditor。
|
|
128
|
+
* 重试/压缩等非 working 指示器走 statusContainer 独立行,不经过这里,不受影响。
|
|
129
|
+
*/
|
|
130
|
+
export class PiUiBorderEditor extends CustomEditor {
|
|
131
|
+
private readonly title: PiUiBorderTitle;
|
|
132
|
+
|
|
133
|
+
constructor(tui: EditorArgs[0], theme: EditorArgs[1], keybindings: EditorArgs[2], inputs: EditorTitleInputs) {
|
|
134
|
+
super(tui, theme, keybindings, { embedWorkingStatus: true });
|
|
135
|
+
this.title = new PiUiBorderTitle(tui, inputs);
|
|
136
|
+
this.attachTitle();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** pi 每次 turn_start/turn_end 都会重设指示器,这里一律换成常驻标题。 */
|
|
140
|
+
setWorkingStatusIndicator(_indicator?: IndicatorSlot): void {
|
|
141
|
+
this.attachTitle();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 状态或模型变化后由入口调用。 */
|
|
145
|
+
sync(): void {
|
|
146
|
+
this.title.sync();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 停掉呼吸灯定时器;pi 切换编辑器时不会调用,入口在 shutdown/关闭特性时调用。 */
|
|
150
|
+
dispose(): void {
|
|
151
|
+
this.title.dispose();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private attachTitle(): void {
|
|
155
|
+
// 标题只有 renderInBorder/renderSpinnerInBorder,用类型断言补足 pi 的指示器类型。
|
|
156
|
+
super.setWorkingStatusIndicator(this.title as unknown as IndicatorSlot);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** 进度条总格数。 */
|
|
4
|
+
export const CONTEXT_BAR_CELLS = 14;
|
|
5
|
+
|
|
6
|
+
const FILLED = "█";
|
|
7
|
+
const EMPTY = "░";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 上下文占用率的语义色。阈值与内置 footer 对齐(>70% 警告,>90% 错误),
|
|
11
|
+
* 正常档位用 success 而非 accent,让进度条读起来像仪表而不是装饰。
|
|
12
|
+
* percent 为 null 表示刚压缩完、占用未知。
|
|
13
|
+
*/
|
|
14
|
+
export function contextColor(percent: number | null): ThemeColor {
|
|
15
|
+
if (percent === null) return "dim";
|
|
16
|
+
if (percent > 90) return "error";
|
|
17
|
+
if (percent > 70) return "warning";
|
|
18
|
+
return "success";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 渲染固定宽度进度条。非零占用至少填一格,否则 1.7% 这类低占用会渲染成全空条,
|
|
23
|
+
* 看起来像数据缺失而不是"还早"。
|
|
24
|
+
*/
|
|
25
|
+
export function renderContextBar(percent: number | null, cells: number = CONTEXT_BAR_CELLS): string {
|
|
26
|
+
const ratio = percent === null ? 0 : Math.min(1, Math.max(0, percent / 100));
|
|
27
|
+
const filled = ratio > 0 ? Math.max(1, Math.round(ratio * cells)) : 0;
|
|
28
|
+
return FILLED.repeat(filled) + EMPTY.repeat(cells - filled);
|
|
29
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { contextColor, renderContextBar } from "./context-bar.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Nerd Font 的分支图标 U+E0A0(nf-pl-branch),与 starship 默认提示符同一字形。
|
|
7
|
+
* 宽度为 1 列,visibleWidth 能正确计算;无 Nerd Font 字体时会显示为豆腐块。
|
|
8
|
+
*/
|
|
9
|
+
const GIT_ICON = "\ue0a0";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 第 1、2 行的标签列宽(含标签后的分隔空格),两条进度条靠它左对齐:`Comate ` / `Context `。
|
|
13
|
+
* 第 1 行的标签由产出该状态的扩展自己补齐,这是跨包视觉契约。
|
|
14
|
+
*/
|
|
15
|
+
const LABEL_COLUMN = 8;
|
|
16
|
+
|
|
17
|
+
/** 会话累计用量,口径与内置 footer 一致。 */
|
|
18
|
+
export type UsageTotals = {
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
cacheWrite: number;
|
|
23
|
+
cost: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function createUsageTotals(): UsageTotals {
|
|
27
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** footer 渲染所需的数据来源,由入口注入,使 footer 不依赖 ExtensionContext。 */
|
|
31
|
+
export type FooterInputs = {
|
|
32
|
+
getContextUsage(): { percent: number | null; contextWindow: number } | undefined;
|
|
33
|
+
getUsageTotals(): UsageTotals;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** footerData 的最小结构要求,避免依赖 pi 未公开导出的内部类型。 */
|
|
37
|
+
export type FooterData = {
|
|
38
|
+
getGitBranch(): string | undefined;
|
|
39
|
+
getExtensionStatuses(): ReadonlyMap<string, string>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** 紧凑的 token 计数格式,与内置 footer 一致。 */
|
|
43
|
+
export function formatTokens(count: number): string {
|
|
44
|
+
if (count < 1000) return count.toString();
|
|
45
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
46
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
47
|
+
if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
|
|
48
|
+
return `${Math.round(count / 1000000)}M`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 把扩展状态里的换行/制表符压成空格,避免撑破 footer 行数。
|
|
53
|
+
* 不动连续空格:第 1、2 行的标签列靠补齐空格对齐,压掉就错位了。
|
|
54
|
+
*/
|
|
55
|
+
function sanitizeStatusText(text: string): string {
|
|
56
|
+
return text.replace(/[\r\n\t]+/g, " ").trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 双行仪表盘 footer,两条进度条置顶。
|
|
61
|
+
*
|
|
62
|
+
* 第 1 行:扩展 status 文本(`@muzi3/comate-quota` 的额度条)+ git 分支
|
|
63
|
+
* 第 2 行:上下文进度条(`Context` 标签 + 14 格条 + 百分比/窗口)+ 累计用量
|
|
64
|
+
*
|
|
65
|
+
* 自定义 footer 会整体替换内置 footer,内置第 3 行的扩展状态会跟着消失,所以扩展状态置于最前。
|
|
66
|
+
* 第 1、2 行都是“标签 + 14 格条 + 计数”,需要对齐,因此约定标签列宽 LABEL_COLUMN:`Context`
|
|
67
|
+
* 由这里补齐,扩展状态行的标签由各自补齐。行尾的分支与累计用量是次要信息,宽度不足时整段丢掉。
|
|
68
|
+
*
|
|
69
|
+
* 空行会被丢掉(没装扩展就没有额度条、不在 git 仓库就没有分支),footer 高度不超过 2 行。
|
|
70
|
+
*
|
|
71
|
+
* 内置 footer 右侧会显示“模型名 • 思考等级”,这里两者都不要:模型名在编辑器边框上
|
|
72
|
+
* 常驻,思考等级由边框颜色区分(每个等级一个 thinking* token)。
|
|
73
|
+
*/
|
|
74
|
+
export class PiUiFooter implements Component {
|
|
75
|
+
private readonly theme: Theme;
|
|
76
|
+
private readonly data: FooterData;
|
|
77
|
+
private readonly inputs: FooterInputs;
|
|
78
|
+
|
|
79
|
+
/** `tui` 是 Component factory 签名的一部分,本组件纯函数式渲染,无需持有。 */
|
|
80
|
+
constructor(_tui: TUI, theme: Theme, data: FooterData, inputs: FooterInputs) {
|
|
81
|
+
this.theme = theme;
|
|
82
|
+
this.data = data;
|
|
83
|
+
this.inputs = inputs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 数据都由 inputs 实时读取,无需缓存失效。 */
|
|
87
|
+
invalidate(): void {}
|
|
88
|
+
|
|
89
|
+
render(width: number): string[] {
|
|
90
|
+
return [this.renderQuotaLine(width), this.renderContextLine(width)].filter(
|
|
91
|
+
(line) => visibleWidth(line) > 0,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 第 1 行:扩展 status 文本 + git 分支。 */
|
|
96
|
+
private renderQuotaLine(width: number): string {
|
|
97
|
+
const statuses = this.collectStatuses();
|
|
98
|
+
const branch = this.renderBranch();
|
|
99
|
+
if (!statuses) return truncateToWidth(branch, width, "");
|
|
100
|
+
|
|
101
|
+
const full = branch ? `${statuses} ${branch}` : statuses;
|
|
102
|
+
if (visibleWidth(full) <= width) return full;
|
|
103
|
+
|
|
104
|
+
// 进度条是这一行的目的,分支是次要信息:放不下就整段丢掉,不截断。
|
|
105
|
+
return truncateToWidth(statuses, width, this.dim("…"));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** git 分支,带 Nerd Font 图标;不在 git 仓库时为空串。 */
|
|
109
|
+
private renderBranch(): string {
|
|
110
|
+
const branch = this.data.getGitBranch();
|
|
111
|
+
if (!branch) return "";
|
|
112
|
+
// 图标用 accent(该主题下为 mauve)保持可扫读,分支名压低为 muted。
|
|
113
|
+
return `${this.theme.fg("accent", GIT_ICON)} ${this.theme.fg("muted", branch)}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 第 2 行:上下文进度条 + 累计用量,标签补齐到 LABEL_COLUMN 与上一行左对齐。 */
|
|
117
|
+
private renderContextLine(width: number): string {
|
|
118
|
+
const theme = this.theme;
|
|
119
|
+
const stats = this.renderUsageStats();
|
|
120
|
+
const usage = this.inputs.getContextUsage();
|
|
121
|
+
if (!usage) return truncateToWidth(stats, width, "");
|
|
122
|
+
|
|
123
|
+
const color = contextColor(usage.percent);
|
|
124
|
+
const gauge = theme.fg(color, renderContextBar(usage.percent));
|
|
125
|
+
const counter = theme.fg(
|
|
126
|
+
color,
|
|
127
|
+
usage.percent === null
|
|
128
|
+
? `? / ${formatTokens(usage.contextWindow)}`
|
|
129
|
+
: `${usage.percent.toFixed(1)}% / ${formatTokens(usage.contextWindow)}`,
|
|
130
|
+
);
|
|
131
|
+
// 标签自带补位空格,不能再走 join(" "),否则进度条起点比第 1 行右移 2 列。
|
|
132
|
+
const labeled = `${theme.fg("dim", "Context".padEnd(LABEL_COLUMN, " "))}${gauge}`;
|
|
133
|
+
|
|
134
|
+
// 变窄时依次丢累计用量、丢标签、丢计数器;进度条与百分比是这一行的目的,不接受被截断。
|
|
135
|
+
const candidates = [
|
|
136
|
+
[labeled, counter, stats],
|
|
137
|
+
[labeled, counter],
|
|
138
|
+
[gauge, counter],
|
|
139
|
+
[gauge],
|
|
140
|
+
];
|
|
141
|
+
for (const parts of candidates) {
|
|
142
|
+
const text = parts.filter(Boolean).join(" ");
|
|
143
|
+
if (visibleWidth(text) <= width) return text;
|
|
144
|
+
}
|
|
145
|
+
return truncateToWidth(gauge, width, "");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 会话累计用量;本轮还没发生用量时为空串。 */
|
|
149
|
+
private renderUsageStats(): string {
|
|
150
|
+
const totals = this.inputs.getUsageTotals();
|
|
151
|
+
const stats: string[] = [];
|
|
152
|
+
if (totals.input) stats.push(`↑${formatTokens(totals.input)}`);
|
|
153
|
+
if (totals.output) stats.push(`↓${formatTokens(totals.output)}`);
|
|
154
|
+
if (totals.cost) stats.push(`$${totals.cost.toFixed(3)}`);
|
|
155
|
+
return this.dim(stats.join(" "));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 暗色包裹;空串原样返回,避免产生只有 ANSI 码、宽度为 0 的“隐形”内容。 */
|
|
159
|
+
private dim(text: string): string {
|
|
160
|
+
return text ? this.theme.fg("dim", text) : "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 其他扩展通过 setStatus 设置的状态,按 key 排序后连接。 */
|
|
164
|
+
private collectStatuses(): string {
|
|
165
|
+
return Array.from(this.data.getExtensionStatuses().entries())
|
|
166
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
167
|
+
.map(([, text]) => sanitizeStatusText(text))
|
|
168
|
+
.filter((text) => text.length > 0)
|
|
169
|
+
.join(" ");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { PiUiBorderEditor } from "./border-editor.ts";
|
|
3
|
+
import { createUsageTotals, PiUiFooter, type UsageTotals } from "./footer.ts";
|
|
4
|
+
import { buildPulseFrames, PULSE_INTERVAL_MS } from "./indicator.ts";
|
|
5
|
+
|
|
6
|
+
/** 可单独开关的三块能力,值即为 /pi-ui 的子命令名。 */
|
|
7
|
+
const FEATURES = ["footer", "indicator", "working"] as const;
|
|
8
|
+
|
|
9
|
+
type Feature = (typeof FEATURES)[number];
|
|
10
|
+
|
|
11
|
+
const COMMAND = "pi-ui";
|
|
12
|
+
const USAGE = `用法:/${COMMAND} <footer|indicator|working> <on|off>`;
|
|
13
|
+
|
|
14
|
+
function isFeature(value: string): value is Feature {
|
|
15
|
+
return (FEATURES as readonly string[]).includes(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Usage = {
|
|
19
|
+
input: number;
|
|
20
|
+
output: number;
|
|
21
|
+
cacheRead: number;
|
|
22
|
+
cacheWrite: number;
|
|
23
|
+
cost: { total: number };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function addUsage(totals: UsageTotals, usage: Usage): void {
|
|
27
|
+
totals.input += usage.input;
|
|
28
|
+
totals.output += usage.output;
|
|
29
|
+
totals.cacheRead += usage.cacheRead;
|
|
30
|
+
totals.cacheWrite += usage.cacheWrite;
|
|
31
|
+
totals.cost += usage.cost.total;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 累计用量口径与内置 footer 一致:assistant + toolResult + branch_summary/compaction。 */
|
|
35
|
+
function totalsFromEntries(entries: SessionEntry[]): UsageTotals {
|
|
36
|
+
const totals = createUsageTotals();
|
|
37
|
+
for (const entry of entries) {
|
|
38
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
39
|
+
addUsage(totals, entry.message.usage);
|
|
40
|
+
} else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage) {
|
|
41
|
+
addUsage(totals, entry.message.usage);
|
|
42
|
+
} else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage) {
|
|
43
|
+
addUsage(totals, entry.usage);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return totals;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export default function (pi: ExtensionAPI) {
|
|
50
|
+
const enabled: Record<Feature, boolean> = { footer: true, indicator: true, working: true };
|
|
51
|
+
|
|
52
|
+
/** 事件里的 ctx 会被新会话替换,footer 通过它读取实时数据,所以每收到事件就刷新引用。 */
|
|
53
|
+
let current: ExtensionContext | undefined;
|
|
54
|
+
let footer: PiUiFooter | undefined;
|
|
55
|
+
let borderEditor: PiUiBorderEditor | undefined;
|
|
56
|
+
let syncedTheme: string | undefined;
|
|
57
|
+
|
|
58
|
+
/** 其他扩展临时替换的边框模型名;undefined 表示用当前会话模型。 */
|
|
59
|
+
let modelOverride: string | undefined;
|
|
60
|
+
|
|
61
|
+
// agent_start → agent_settled 覆盖整轮(含工具调用与重试),空闲时保留上一轮耗时。
|
|
62
|
+
let turnStartedAt: number | undefined;
|
|
63
|
+
let lastTurnSeconds: number | undefined;
|
|
64
|
+
|
|
65
|
+
const timerSeconds = (): number | undefined =>
|
|
66
|
+
turnStartedAt === undefined ? lastTurnSeconds : (Date.now() - turnStartedAt) / 1000;
|
|
67
|
+
|
|
68
|
+
const installFooter = (ctx: ExtensionContext): void => {
|
|
69
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
70
|
+
footer = new PiUiFooter(tui, theme, footerData, {
|
|
71
|
+
getContextUsage: () => current?.getContextUsage(),
|
|
72
|
+
getUsageTotals: () => totalsFromEntries(current?.sessionManager.getEntries() ?? []),
|
|
73
|
+
});
|
|
74
|
+
return footer;
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const removeFooter = (ctx: ExtensionContext): void => {
|
|
79
|
+
ctx.ui.setFooter(undefined);
|
|
80
|
+
footer = undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 闪烁帧是构建期快照,换主题后不会自动更新;这里按主题名做一次惰性重建。
|
|
85
|
+
* 边框标题自己按主题名缓存帧(见 PiUiBorderTitle.currentFrames),不需要这步;
|
|
86
|
+
* 这里只为内置指示器重新配置,即 working 关闭时的回退路径。
|
|
87
|
+
*/
|
|
88
|
+
const syncIndicator = (ctx: ExtensionContext): void => {
|
|
89
|
+
const name = ctx.ui.theme.name;
|
|
90
|
+
if (name === syncedTheme) return;
|
|
91
|
+
syncedTheme = name;
|
|
92
|
+
if (!enabled.indicator) return;
|
|
93
|
+
ctx.ui.setWorkingIndicator({ frames: buildPulseFrames(ctx.ui.theme), intervalMs: PULSE_INTERVAL_MS });
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** 用自己的编辑器子类接管边框标题,让模型名与耗时在空闲时也留在边框上。 */
|
|
97
|
+
const installBorderEditor = (ctx: ExtensionContext): void => {
|
|
98
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
99
|
+
borderEditor = new PiUiBorderEditor(tui, theme, keybindings, {
|
|
100
|
+
getModelId: () => modelOverride ?? current?.model?.id,
|
|
101
|
+
getTimerSeconds: timerSeconds,
|
|
102
|
+
isStreaming: () => turnStartedAt !== undefined,
|
|
103
|
+
isPulseEnabled: () => enabled.indicator,
|
|
104
|
+
getTheme: () => ctx.ui.theme,
|
|
105
|
+
});
|
|
106
|
+
return borderEditor;
|
|
107
|
+
});
|
|
108
|
+
// 内置指示器只在流式期间出现,且已被标题接管,别再让它每轮建/销毁一个 Loader。
|
|
109
|
+
ctx.ui.setWorkingVisible(false);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** 换回内置编辑器,还原 pi 默认行为:流式期间在边框显示模型名。 */
|
|
113
|
+
const removeBorderEditor = (ctx: ExtensionContext): void => {
|
|
114
|
+
borderEditor?.dispose();
|
|
115
|
+
borderEditor = undefined;
|
|
116
|
+
ctx.ui.setEditorComponent(undefined);
|
|
117
|
+
ctx.ui.setWorkingVisible(true);
|
|
118
|
+
ctx.ui.setWorkingMessage(ctx.model?.id);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const apply = (ctx: ExtensionContext, feature: Feature, on: boolean): void => {
|
|
122
|
+
enabled[feature] = on;
|
|
123
|
+
switch (feature) {
|
|
124
|
+
case "footer":
|
|
125
|
+
if (on) installFooter(ctx);
|
|
126
|
+
else removeFooter(ctx);
|
|
127
|
+
return;
|
|
128
|
+
case "indicator":
|
|
129
|
+
syncedTheme = undefined;
|
|
130
|
+
if (on) syncIndicator(ctx);
|
|
131
|
+
else ctx.ui.setWorkingIndicator(undefined);
|
|
132
|
+
borderEditor?.sync();
|
|
133
|
+
return;
|
|
134
|
+
case "working":
|
|
135
|
+
if (on) installBorderEditor(ctx);
|
|
136
|
+
else removeBorderEditor(ctx);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 其他扩展在占用会话期间临时替换边框上的模型名(典型场景:压缩用了另一个模型)。
|
|
143
|
+
* 传非空字符串覆盖,传 undefined 恢复;没扩展发布时这条通道无副作用。
|
|
144
|
+
* 订阅由 pi 在会话替换/重载时统一退订(loader 跟踪 eventBus 订阅),这里不手动清理。
|
|
145
|
+
*/
|
|
146
|
+
pi.events.on("pi-ui:model-override", (data) => {
|
|
147
|
+
modelOverride = typeof data === "string" && data.length > 0 ? data : undefined;
|
|
148
|
+
borderEditor?.sync();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
pi.on("session_start", (_event, ctx) => {
|
|
152
|
+
current = ctx;
|
|
153
|
+
syncedTheme = undefined;
|
|
154
|
+
modelOverride = undefined;
|
|
155
|
+
syncIndicator(ctx);
|
|
156
|
+
if (enabled.working) installBorderEditor(ctx);
|
|
157
|
+
else removeBorderEditor(ctx);
|
|
158
|
+
if (enabled.footer) installFooter(ctx);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
pi.on("model_select", (_event, ctx) => {
|
|
162
|
+
current = ctx;
|
|
163
|
+
if (!enabled.working) ctx.ui.setWorkingMessage(ctx.model?.id);
|
|
164
|
+
borderEditor?.sync();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
pi.on("agent_start", (_event, ctx) => {
|
|
168
|
+
current = ctx;
|
|
169
|
+
syncIndicator(ctx);
|
|
170
|
+
turnStartedAt = Date.now();
|
|
171
|
+
borderEditor?.sync();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
175
|
+
current = ctx;
|
|
176
|
+
if (turnStartedAt !== undefined) {
|
|
177
|
+
lastTurnSeconds = (Date.now() - turnStartedAt) / 1000;
|
|
178
|
+
turnStartedAt = undefined;
|
|
179
|
+
}
|
|
180
|
+
// 停下呼吸灯,并重绘一次让耗时定格为上一轮结果。
|
|
181
|
+
borderEditor?.sync();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
pi.on("session_shutdown", () => {
|
|
185
|
+
borderEditor?.dispose();
|
|
186
|
+
borderEditor = undefined;
|
|
187
|
+
footer = undefined;
|
|
188
|
+
current = undefined;
|
|
189
|
+
modelOverride = undefined;
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
pi.registerCommand(COMMAND, {
|
|
193
|
+
description: "查看或开关 pi-ui 的 footer、呼吸灯与编辑器边框标题",
|
|
194
|
+
handler: async (args, ctx) => {
|
|
195
|
+
current = ctx;
|
|
196
|
+
const [feature, value] = args.trim().toLowerCase().split(/\s+/);
|
|
197
|
+
|
|
198
|
+
if (!feature) {
|
|
199
|
+
const lines = FEATURES.map((key) => `${key}: ${enabled[key] ? "on" : "off"}`);
|
|
200
|
+
ctx.ui.notify(`${lines.join(" ")}\n${USAGE}`, "info");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (!isFeature(feature) || (value !== "on" && value !== "off")) {
|
|
204
|
+
ctx.ui.notify(USAGE, "warning");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
apply(ctx, feature, value === "on");
|
|
208
|
+
ctx.ui.notify(`${feature} ${value}`, "info");
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/** 帧间隔,与原版 pi-catppuccin-tui 保持一致。 */
|
|
4
|
+
export const PULSE_INTERVAL_MS = 120;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 闪烁序列 `· • ● ● • ·`,帧形沿用原版。
|
|
8
|
+
*
|
|
9
|
+
* 原实现把 Macchiato 的十六进制色写死在帧字符串里,换主题后指示器颜色不会跟着变;
|
|
10
|
+
* 这里保留帧形,颜色改为当前主题的 token(dim → accent → borderAccent)。
|
|
11
|
+
*/
|
|
12
|
+
const PULSE: ReadonlyArray<readonly [ThemeColor, string]> = [
|
|
13
|
+
["dim", "·"],
|
|
14
|
+
["dim", "•"],
|
|
15
|
+
["accent", "●"],
|
|
16
|
+
["borderAccent", "●"],
|
|
17
|
+
["muted", "•"],
|
|
18
|
+
["dim", "·"],
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 构建闪烁帧。帧是构建期快照(`theme.fg()` 在此刻求值),
|
|
23
|
+
* 主题切换后需要重新调用本函数才会生效。
|
|
24
|
+
*/
|
|
25
|
+
export function buildPulseFrames(theme: Theme): string[] {
|
|
26
|
+
return PULSE.map(([color, glyph]) => theme.fg(color, glyph));
|
|
27
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@muzi3/pi-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension: 双行仪表盘 footer + 主题化工作指示器 + Catppuccin Mocha 层次增强主题",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-theme",
|
|
9
|
+
"catppuccin",
|
|
10
|
+
"footer"
|
|
11
|
+
],
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"author": "muzi",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://codeberg.org/muzi/pi-packages.git",
|
|
17
|
+
"directory": "packages/pi-ui"
|
|
18
|
+
},
|
|
19
|
+
"peerDependencies": {
|
|
20
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
21
|
+
"@earendil-works/pi-tui": "*"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./extensions/index.ts"
|
|
26
|
+
],
|
|
27
|
+
"themes": [
|
|
28
|
+
"./themes"
|
|
29
|
+
]
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"extensions",
|
|
33
|
+
"themes",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
|
|
3
|
+
"name": "pi-ui-catppuccin",
|
|
4
|
+
"vars": {
|
|
5
|
+
"rosewater": "#f5e0dc",
|
|
6
|
+
"flamingo": "#f2cdcd",
|
|
7
|
+
"pink": "#f5c2e7",
|
|
8
|
+
"mauve": "#cba6f7",
|
|
9
|
+
"red": "#f38ba8",
|
|
10
|
+
"redBg": "#493446",
|
|
11
|
+
"maroon": "#eba0ac",
|
|
12
|
+
"peach": "#fab387",
|
|
13
|
+
"yellow": "#f9e2af",
|
|
14
|
+
"green": "#a6e3a1",
|
|
15
|
+
"teal": "#94e2d5",
|
|
16
|
+
"sky": "#89dceb",
|
|
17
|
+
"sapphire": "#74c7ec",
|
|
18
|
+
"blue": "#89b4fa",
|
|
19
|
+
"lavender": "#b4befe",
|
|
20
|
+
"text": "#cdd6f4",
|
|
21
|
+
"subtext1": "#bac2de",
|
|
22
|
+
"subtext0": "#a6adc8",
|
|
23
|
+
"overlay2": "#9399b2",
|
|
24
|
+
"overlay1": "#7f849c",
|
|
25
|
+
"overlay0": "#6c7086",
|
|
26
|
+
"surface2": "#585b70",
|
|
27
|
+
"surface1": "#45475a",
|
|
28
|
+
"surface0": "#313244",
|
|
29
|
+
"base": "#1e1e2e",
|
|
30
|
+
"mantle": "#181825",
|
|
31
|
+
"crust": "#11111b"
|
|
32
|
+
},
|
|
33
|
+
"colors": {
|
|
34
|
+
"accent": "mauve",
|
|
35
|
+
"border": "surface2",
|
|
36
|
+
"borderAccent": "lavender",
|
|
37
|
+
"borderMuted": "surface1",
|
|
38
|
+
"success": "green",
|
|
39
|
+
"error": "red",
|
|
40
|
+
"warning": "yellow",
|
|
41
|
+
"muted": "subtext0",
|
|
42
|
+
"dim": "overlay1",
|
|
43
|
+
"text": "text",
|
|
44
|
+
"thinkingText": "subtext1",
|
|
45
|
+
"selectedBg": "surface1",
|
|
46
|
+
"searchMatchBg": "surface2",
|
|
47
|
+
"searchMatchText": "text",
|
|
48
|
+
"scrollbarTrack": "surface0",
|
|
49
|
+
"scrollbarThumb": "overlay0",
|
|
50
|
+
"userMessageBg": "surface0",
|
|
51
|
+
"userMessageText": "text",
|
|
52
|
+
"customMessageBg": "mantle",
|
|
53
|
+
"customMessageText": "text",
|
|
54
|
+
"customMessageLabel": "mauve",
|
|
55
|
+
"toolPendingBg": "mantle",
|
|
56
|
+
"toolSuccessBg": "surface0",
|
|
57
|
+
"toolErrorBg": "redBg",
|
|
58
|
+
"toolTitle": "blue",
|
|
59
|
+
"toolOutput": "overlay2",
|
|
60
|
+
"mdHeading": "lavender",
|
|
61
|
+
"mdLink": "blue",
|
|
62
|
+
"mdLinkUrl": "sapphire",
|
|
63
|
+
"mdCode": "peach",
|
|
64
|
+
"mdCodeBlock": "subtext1",
|
|
65
|
+
"mdCodeBlockBorder": "surface1",
|
|
66
|
+
"mdQuote": "subtext0",
|
|
67
|
+
"mdQuoteBorder": "surface1",
|
|
68
|
+
"mdHr": "surface2",
|
|
69
|
+
"mdListBullet": "overlay2",
|
|
70
|
+
"toolDiffAdded": "green",
|
|
71
|
+
"toolDiffRemoved": "red",
|
|
72
|
+
"toolDiffContext": "subtext0",
|
|
73
|
+
"syntaxComment": "overlay2",
|
|
74
|
+
"syntaxKeyword": "mauve",
|
|
75
|
+
"syntaxFunction": "blue",
|
|
76
|
+
"syntaxVariable": "maroon",
|
|
77
|
+
"syntaxString": "green",
|
|
78
|
+
"syntaxNumber": "peach",
|
|
79
|
+
"syntaxType": "yellow",
|
|
80
|
+
"syntaxOperator": "sky",
|
|
81
|
+
"syntaxPunctuation": "overlay2",
|
|
82
|
+
"thinkingOff": "surface1",
|
|
83
|
+
"thinkingMinimal": "surface2",
|
|
84
|
+
"thinkingLow": "blue",
|
|
85
|
+
"thinkingMedium": "sapphire",
|
|
86
|
+
"thinkingHigh": "mauve",
|
|
87
|
+
"thinkingXhigh": "maroon",
|
|
88
|
+
"thinkingMax": "red",
|
|
89
|
+
"bashMode": "peach"
|
|
90
|
+
},
|
|
91
|
+
"export": {
|
|
92
|
+
"pageBg": "#11111b",
|
|
93
|
+
"cardBg": "#1e1e2e",
|
|
94
|
+
"infoBg": "#313244"
|
|
95
|
+
}
|
|
96
|
+
}
|