@muzi3/pi-ui 0.1.1 → 0.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @muzi3/pi-ui
2
2
 
3
- Pi 扩展:把页脚换成双行上下文仪表盘,并在编辑器边框上常驻显示模型名与耗时,主题跟着当前主题走。
3
+ Pi 扩展:把页脚换成双行上下文仪表盘,在编辑器边框上常驻显示模型名与耗时,并给折叠的 thinking 标签加 spinner 动画,主题跟着当前主题走。
4
4
 
5
5
  替代 `pi-catppuccin-tui` 的自实现版本,修掉了原实现的两处问题:指示器颜色和多处 token 写死了 Macchiato
6
6
  的十六进制色(换主题后错色),以及自定义 footer 整体替换内置 footer 后、其他扩展的 `setStatus` 状态
@@ -11,8 +11,9 @@ Pi 扩展:把页脚换成双行上下文仪表盘,并在编辑器边框上
11
11
  | 能力 | 说明 |
12
12
  |------|------|
13
13
  | `footer` | 双行仪表盘页脚,两条进度条置顶,替换内置 footer |
14
- | `indicator` | 闪烁帧 `· • ● ● • ·` @120ms,颜色取自主题 token;关掉后不显示呼吸灯(内置指示器回退为 pi 默认) |
15
- | `working` | 编辑器边框上常驻显示模型名与耗时,是否在生成只由呼吸灯表达 |
14
+ | `indicator` | 盲文 spinner `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` @80ms(与 pi 自带 Loader 逐帧相同、`accent` 单色);关掉后不显示(内置指示器回退为 pi 默认) |
15
+ | `working` | 编辑器边框上常驻显示模型名与耗时,是否在生成只由盲文 spinner 表达 |
16
+ | `thinking` | 折叠的 thinking 块标签前加同款盲文 spinner `⠋ Thinking...` @80ms(复用 `indicator` 的帧),只在流式期间转 |
16
17
 
17
18
  ### footer 布局
18
19
 
@@ -22,11 +23,12 @@ Context ██░░░░░░░░░░░░ 1.7% / 272k ↑74.7k ↓3.8
22
23
  ```
23
24
 
24
25
  色:标签 `muted`|进度条 按占用率 绿/黄/红|占比 跟条同色|`/ 窗口`、`/ ¥` 与未知占位 `dim`|
25
- `↑` `mdLink` 蓝、`↓` `syntaxNumber` 桃、`$` `syntaxOperator` 青|分支 `accent` + `text`
26
+ `↑` `mdLink` 蓝、`↓` `syntaxNumber` 桃、`$` `syntaxOperator` 青|分支 `accent`(图标与分支名同色)
26
27
 
27
28
  - 第 1 行:其他扩展通过 `setStatus` 设置的文本(如 `@muzi3/comate-quota` 的额度条)+ git 分支。
28
29
  分支前是 Nerd Font 的 git 图标 `U+E0A0`(nf-pl-branch,与 starship 默认提示符同一字形,
29
- `accent` 色),**需要 Nerd Font 字体**,否则显示为豆腐块;分支名用 `text`(内容用正文色)
30
+ `accent` 色),**需要 Nerd Font 字体**,否则显示为豆腐块;分支名同色 `accent`(图标与名字
31
+ 合成一整块,在行尾立成一个锚点)
30
32
  - 第 2 行:上下文进度条(14 格)+ `百分比 / 窗口` + 累计用量(`↑in ↓out $cost`)。
31
33
  进度条配色按占用率分级:`<70%` 绿(`success`)、`70~90%` 黄(`warning`)、`>90%` 红(`error`);
32
34
  刚压缩完占用未知时显示为暗色 `?`
@@ -48,7 +50,7 @@ Context ██░░░░░░░░░░░░ 1.7% / 272k ↑74.7k ↓3.8
48
50
 
49
51
  ```
50
52
  ── claude-sonnet-4-5 ⏱ 12s ──────────────────────────────── 空闲
51
- ── ● claude-sonnet-4-5 ⏱ 12s ────────────────────────────── 流式
53
+ ── ⠼ claude-sonnet-4-5 ⏱ 12s ────────────────────────────── 流式
52
54
  >
53
55
  ────────────────────────────────────────────────────────────────────────
54
56
  ```
@@ -58,11 +60,21 @@ Context ██░░░░░░░░░░░░ 1.7% / 272k ↑74.7k ↓3.8
58
60
  承载不了“常驻”;所以子类覆写 `setWorkingStatusIndicator`,忽略 pi 的挂/清,恒挂自己的标题。
59
61
  边框拼接、截断、`↑ N more` 滚动提示全部沿用 `CustomEditor.renderTopBorder`,没有重写排版
60
62
  - 输入被滚动时不用取舍:`↑ N more` 会与标题并列出现在同一行边框上
61
- (`── ● model ⏱ 12s ──── ↑ 5 more ────`),这是继承来的行为,不是额外实现
62
- - 呼吸灯:流式为 `· • ● ● • ·` @120ms(颜色按主题 token 递进)。不闪的时候完全不显示——空闲、
63
- 或关掉 `indicator` 时都不留静止的点(静止的点反而更难看出在不在生成);代价是模型名会左右
64
- 移 2 列,在以 `─` 为主的边框上几乎看不出来
65
- - 耗时口径与旧版 footer 一致,只是位置变了:流式为已进行秒数(随呼吸灯帧重绘跳秒),
63
+ (`── ⠼ model ⏱ 12s ──── ↑ 5 more ────`),这是继承来的行为,不是额外实现
64
+ - 指示器:流式为 `⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏` @80ms,帧序列与间隔都对齐 pi 自带 Loader(`@earendil-works/pi-tui`
65
+ 的 `DEFAULT_FRAMES` / `DEFAULT_INTERVAL_MS`),颜色跟内置 working 指示器一样用 `accent` 单色。
66
+ 用盲文而不是 `▘▝▗▖`/`◰◳◲◱` 这类整格块:后者在等宽字体下几乎占满整格,在边框上比正文还抢眼;
67
+ 盲文墨水轻、不挑字体。10 帧在 `visibleWidth` 下都是 1 列,边框标题不抖。
68
+ 早先试过 4 帧矩形(`⠁⠈⠠⠄` 绕四角 + `dim→muted→accent→borderAccent` 明暗递进):帧数翻到 10 帧后
69
+ 逐帧换色会变成“亮度在扫”,反而不像 spinner,所以回到单色。
70
+ 不转的时候完全不显示——空闲、或关掉 `indicator` 时都不留静止帧(静止的帧反而
71
+ 更难看出在不在生成);代价只是模型名左右移 1 列,在以 `─` 为主的边框上几乎看不出来
72
+ - 配色:模型名直接取编辑器**当前**的 `borderColor`(`this.borderColor`),边框是什么色它就是
73
+ 什么色。pi 会按 thinking level 换这个函数(`getThinkingBorderColor`:off `overlay0`、low `blue`、
74
+ medium `sapphire`、high `mauve`……),bash 模式另有 `getBashModeBorderColor`,所以写死任何 token
75
+ 都会在切换后与边框脱色(例如 medium 下边框是青色而模型名还是灰的);
76
+ `⏱ 耗时` 为 `dim`
77
+ - 耗时口径与旧版 footer 一致,只是位置变了:流式为已进行秒数(随 spinner 帧重绘跳秒),
66
78
  空闲为上一轮总耗时;宽度不足时先丢掉 `⏱ 耗时`,再逐字截断模型名
67
79
  - 退出/关闭 `working` 会换回内置编辑器,还原 pi 默认:仅流式期间在边框显示模型名
68
80
  - 需要 pi `>= 0.85`(`CustomEditor` 的 `embedWorkingStatus` 与 `setWorkingStatusIndicator` 从该版本才有);
@@ -89,15 +101,44 @@ pi.events.emit("pi-ui:model-override", undefined); // 恢复会话模型
89
101
  2. 第 2 行:完整(进度条 + 计数 + 累计用量)→ 丢累计用量 → 丢 `Context ` 标签 → 丢百分比/窗口
90
102
  → 只剩进度条;进度条不接受被截断
91
103
 
104
+ ## thinking spinner 动画
105
+
106
+ 折叠 thinking 块的标签(`hideThinkingBlock` 或 Ctrl+T 收起时显示的那行)前面加盲文 spinner:
107
+
108
+ ```
109
+ ⠋ Thinking... ← 流式中,⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ @80ms(复用边框指示器的帧)
110
+ ⋯ Thinking... ← 空闲/结束后回落的静态帧
111
+ ```
112
+
113
+ 帧表不在这里另写一份,直接复用 `indicator.ts` 的 `buildSpinnerFrames`:两处观感要一致,两份列表
114
+ 迟早漂移。频率也用同一个 `SPINNER_INTERVAL_MS`,所以标签和边框 spinner 同速。
115
+
116
+ - pi 只提供静态的 `setHiddenThinkingLabel`,动画靠定时器反复改写标签实现。每次改写都会重绘
117
+ 整条 transcript,所以定时器**只在 assistant 消息流式期间**开表(`message_start` 开、
118
+ `message_end` 停,`agent_settled` 兜住用户中断);流式期间 pi 本来就在逐 token 重绘,这点
119
+ 额外开销看不出来,空闲时零成本
120
+ - 一轮里可能有多条 assistant 消息、中间夹着工具调用,动画跟**消息**粒度走而不是跟轮次走:
121
+ 工具执行期间标签保持静态,不会被误看成还在思考
122
+ - 配色:帧沿用 `accent`(同边框指示器),尾部文本 `thinkingText`。尾部要单独套一次
123
+ `thinkingText`,因为 pi 把整串包进 `fg("thinkingText", label)`,内层 fg 的 reset 会让
124
+ 后面的行内文本不再跟随 `thinkingText`,颜色塌成终端默认色
125
+ - 帧宽一致(盲文各占 1 列),标签不会左右抖;帧是每帧现取主题色(换主题后立刻跟上),不像边框
126
+ spinner 那样需要按主题名重建缓存
127
+ - 只在 thinking 块**折叠**时可见;Ctrl+T 展开思考原文时这个标签不显示,动画照旧开着(不额外
128
+ 探测 pi 内部的 `hideThinkingBlock`,它没有对外读接口)
129
+
130
+ 关闭 `thinking` 会调 `setHiddenThinkingLabel(undefined)` 还原 pi 默认的 `Thinking...`。
131
+
92
132
  ## 命令
93
133
 
94
134
  ```
95
- /pi-ui # 查看三块能力的开关状态
96
- /pi-ui footer|indicator|working on|off # 单独开关
135
+ /pi-ui # 查看四块能力的开关状态
136
+ /pi-ui footer|indicator|working|thinking on|off # 单独开关
97
137
  ```
98
138
 
99
139
  关闭 `footer` 会恢复 pi 内置 footer;关闭 `working` 会换回内置编辑器(流式期间才显示模型名);关闭
100
- `indicator` 会让呼吸灯不再显示。开关只在本次会话内有效,不写盘。
140
+ `indicator` 会让 spinner 不再显示;关闭 `thinking` 会把折叠 thinking 标签还原成 pi 默认文案。
141
+ 开关只在本次会话内有效,不写盘。
101
142
 
102
143
  ## 主题
103
144
 
@@ -116,6 +157,8 @@ pi.events.emit("pi-ui:model-override", undefined); // 恢复会话模型
116
157
  | `mdQuoteBorder` | `surface2` | `surface1` | 同上,引用属于次要结构 |
117
158
  | `mdHeading` | `mauve` | `lavender` | `mauve` 同时是 `accent`/`mdListBullet`/`syntaxKeyword`/`thinkingHigh`,标题再占用就分不出主次 |
118
159
  | `mdListBullet` | `mauve` | `overlay2` | 列表符号属于次要装饰,降为灰色 |
160
+ | `thinkingOff` | `surface1` | `overlay0` | 1.80:1 时编辑框边框(thinking off 档)几乎看不见,模型名也一致地暗;换成 `overlay0` 后 3.36:1 |
161
+ | `thinkingMinimal` | `surface2` | `overlay1` | 随 `thinkingOff` 上移,否则 off(3.36)会比 minimal(2.46)还亮,暗端递进反转 |
119
162
  | `thinkingXhigh` | `red` | `maroon` | 给 `thinkingMax` 让出 `red`,形成 `mauve → maroon → red` 的递进 |
120
163
  | `thinkingMax` | 未定义 | `red` | 补上最高档,取代回落的默认色 |
121
164
 
@@ -1,7 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth, visibleWidth, type TUI } from "@earendil-works/pi-tui";
4
- import { buildPulseFrames, PULSE_INTERVAL_MS } from "./indicator.ts";
4
+ import { buildSpinnerFrames, SPINNER_INTERVAL_MS } from "./indicator.ts";
5
5
 
6
6
  export function formatDuration(seconds: number): string {
7
7
  const total = Math.max(0, Math.floor(seconds));
@@ -16,8 +16,8 @@ export type EditorTitleInputs = {
16
16
  /** 流式中为已进行秒数,空闲时为上一轮耗时秒数,无数据为 undefined。 */
17
17
  getTimerSeconds(): number | undefined;
18
18
  isStreaming(): boolean;
19
- /** 关掉后不显示呼吸灯(而不是停在静止帧)。 */
20
- isPulseEnabled(): boolean;
19
+ /** 关掉后不显示 spinner(而不是停在静止帧)。 */
20
+ isSpinnerEnabled(): boolean;
21
21
  /** 编辑器主题(getEditorTheme)只有 borderColor/selectList,配色要另取实时主题。 */
22
22
  getTheme(): Theme;
23
23
  };
@@ -32,27 +32,29 @@ type BorderTitle = {
32
32
  };
33
33
 
34
34
  /**
35
- * 编辑器边框上的常驻标题:`<呼吸灯> <模型名> ⏱ 耗时`,空闲时也在(呼吸灯不闪则不显示)。
35
+ * 编辑器边框上的常驻标题:`<盲文 spinner> <模型名> ⏱ 耗时`,空闲时也在(不转则不显示)。
36
36
  *
37
37
  * 帧由自己按时间算,流式时用定时器驱动重绘(pi 的 WorkingStatusIndicator 靠 Loader 同理)。
38
38
  */
39
39
  class PiUiBorderTitle implements BorderTitle {
40
40
  private readonly tui: TUI;
41
41
  private readonly inputs: EditorTitleInputs;
42
+ private readonly getBorderColor: (text: string) => string;
42
43
  private ticker: ReturnType<typeof setInterval> | undefined;
43
44
  private framesKey: string | undefined;
44
45
  private frames: string[] = [];
45
46
 
46
- constructor(tui: TUI, inputs: EditorTitleInputs) {
47
+ constructor(tui: TUI, inputs: EditorTitleInputs, getBorderColor: (text: string) => string) {
47
48
  this.tui = tui;
48
49
  this.inputs = inputs;
50
+ this.getBorderColor = getBorderColor;
49
51
  }
50
52
 
51
- /** 状态或模型变化后调用:立即重绘,并按是否流式启停呼吸灯。 */
53
+ /** 状态或模型变化后调用:立即重绘,并按是否流式启停 spinner。 */
52
54
  sync(): void {
53
55
  if (this.inputs.isStreaming()) {
54
56
  if (!this.ticker) {
55
- this.ticker = setInterval(() => this.tui.requestRender(), PULSE_INTERVAL_MS);
57
+ this.ticker = setInterval(() => this.tui.requestRender(), SPINNER_INTERVAL_MS);
56
58
  }
57
59
  } else {
58
60
  this.stop();
@@ -68,12 +70,14 @@ class PiUiBorderTitle implements BorderTitle {
68
70
  renderInBorder(width: number): string {
69
71
  const theme = this.inputs.getTheme();
70
72
  const model = this.inputs.getModelId();
71
- const pulse = this.renderPulse(theme);
73
+ const spinner = this.renderSpinner(theme);
72
74
 
73
- // 不闪的时候不占位:留一个静止的点比没有更难看出在不在生成。
75
+ // 不转的时候不占位:留一个静止的帧比没有更难看出在不在生成。
74
76
  const parts: string[] = [];
75
- if (pulse !== undefined) parts.push(pulse);
76
- if (model !== undefined) parts.push(theme.fg("text", model));
77
+ if (spinner !== undefined) parts.push(spinner);
78
+ // 与编辑框边框同色:直接用编辑器当前的 borderColor,边框换成什么色(thinking
79
+ // level、bash 模式、换主题)标题就跟着变,不需要自己维护 token 映射。
80
+ if (model !== undefined) parts.push(this.getBorderColor(model));
77
81
  const head = parts.join(" ");
78
82
 
79
83
  const seconds = this.inputs.getTimerSeconds();
@@ -86,10 +90,10 @@ class PiUiBorderTitle implements BorderTitle {
86
90
  return truncateToWidth(head, width, "");
87
91
  }
88
92
 
89
- /** 边框窄到放不下标题时只留呼吸灯;不闪时交给外层画普通边框。 */
93
+ /** 边框窄到放不下标题时只留 spinner;不转时交给外层画普通边框。 */
90
94
  renderSpinnerInBorder(width: number): string {
91
- const pulse = this.renderPulse(this.inputs.getTheme());
92
- return pulse === undefined ? "" : truncateToWidth(pulse, width, "");
95
+ const spinner = this.renderSpinner(this.inputs.getTheme());
96
+ return spinner === undefined ? "" : truncateToWidth(spinner, width, "");
93
97
  }
94
98
 
95
99
  private stop(): void {
@@ -103,16 +107,16 @@ class PiUiBorderTitle implements BorderTitle {
103
107
  private currentFrames(theme: Theme): string[] {
104
108
  if (theme.name !== this.framesKey) {
105
109
  this.framesKey = theme.name;
106
- this.frames = buildPulseFrames(theme);
110
+ this.frames = buildSpinnerFrames(theme);
107
111
  }
108
112
  return this.frames;
109
113
  }
110
114
 
111
- /** 流式时按时间取帧(与定时器同频,无需自增索引);不闪时返回 undefined,即完全不显示。 */
112
- private renderPulse(theme: Theme): string | undefined {
113
- if (!this.inputs.isPulseEnabled() || !this.inputs.isStreaming()) return undefined;
115
+ /** 流式时按时间取帧(与定时器同频,无需自增索引);不转时返回 undefined,即完全不显示。 */
116
+ private renderSpinner(theme: Theme): string | undefined {
117
+ if (!this.inputs.isSpinnerEnabled() || !this.inputs.isStreaming()) return undefined;
114
118
  const frames = this.currentFrames(theme);
115
- return frames[Math.floor(Date.now() / PULSE_INTERVAL_MS) % frames.length];
119
+ return frames[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % frames.length];
116
120
  }
117
121
  }
118
122
 
@@ -132,7 +136,8 @@ export class PiUiBorderEditor extends CustomEditor {
132
136
 
133
137
  constructor(tui: EditorArgs[0], theme: EditorArgs[1], keybindings: EditorArgs[2], inputs: EditorTitleInputs) {
134
138
  super(tui, theme, keybindings, { embedWorkingStatus: true });
135
- this.title = new PiUiBorderTitle(tui, inputs);
139
+ // 延迟取:pi 会用 getThinkingBorderColor / getBashModeBorderColor 整体替换 borderColor。
140
+ this.title = new PiUiBorderTitle(tui, inputs, (text) => this.borderColor(text));
136
141
  this.attachTitle();
137
142
  }
138
143
 
@@ -146,7 +151,7 @@ export class PiUiBorderEditor extends CustomEditor {
146
151
  this.title.sync();
147
152
  }
148
153
 
149
- /** 停掉呼吸灯定时器;pi 切换编辑器时不会调用,入口在 shutdown/关闭特性时调用。 */
154
+ /** 停掉 spinner 定时器;pi 切换编辑器时不会调用,入口在 shutdown/关闭特性时调用。 */
150
155
  dispose(): void {
151
156
  this.title.dispose();
152
157
  }
@@ -110,9 +110,9 @@ export class PiUiFooter implements Component {
110
110
  private renderBranch(): string {
111
111
  const branch = this.data.getGitBranch();
112
112
  if (!branch) return "";
113
- // 图标用 accent(该主题下为 mauve)做标识;分支名是内容,用 text——压到 muted/dim
114
- // 会跟标签、次要数字混成一片灰。
115
- return `${this.theme.fg("accent", GIT_ICON)} ${this.theme.fg("text", branch)}`;
113
+ // 图标与分支名同色:整块 accent 让分支在行尾立成一个锚点。名字单独压成灰色会
114
+ // 读成"图标是装饰、名字是正文",两种颜色反而把这一块拆散。
115
+ return this.theme.fg("accent", `${GIT_ICON} ${branch}`);
116
116
  }
117
117
 
118
118
  /** 第 2 行:上下文进度条 + 累计用量,标签补齐到 LABEL_COLUMN 与上一行左对齐。 */
@@ -1,10 +1,11 @@
1
1
  import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
2
2
  import { PiUiBorderEditor } from "./border-editor.ts";
3
3
  import { createUsageTotals, PiUiFooter, type UsageTotals } from "./footer.ts";
4
- import { buildPulseFrames, PULSE_INTERVAL_MS } from "./indicator.ts";
4
+ import { buildSpinnerFrames, SPINNER_INTERVAL_MS } from "./indicator.ts";
5
+ import { ThinkingDotsAnimator } from "./thinking-label.ts";
5
6
 
6
- /** 可单独开关的三块能力,值即为 /pi-ui 的子命令名。 */
7
- const FEATURES = ["footer", "indicator", "working"] as const;
7
+ /** 可单独开关的四块能力,值即为 /pi-ui 的子命令名。 */
8
+ const FEATURES = ["footer", "indicator", "working", "thinking"] as const;
8
9
 
9
10
  type Feature = (typeof FEATURES)[number];
10
11
 
@@ -47,7 +48,7 @@ function totalsFromEntries(entries: SessionEntry[]): UsageTotals {
47
48
  }
48
49
 
49
50
  export default function (pi: ExtensionAPI) {
50
- const enabled: Record<Feature, boolean> = { footer: true, indicator: true, working: true };
51
+ const enabled: Record<Feature, boolean> = { footer: true, indicator: true, working: true, thinking: true };
51
52
 
52
53
  /** 事件里的 ctx 会被新会话替换,footer 通过它读取实时数据,所以每收到事件就刷新引用。 */
53
54
  let current: ExtensionContext | undefined;
@@ -58,6 +59,9 @@ export default function (pi: ExtensionAPI) {
58
59
  /** 其他扩展临时替换的边框模型名;undefined 表示用当前会话模型。 */
59
60
  let modelOverride: string | undefined;
60
61
 
62
+ /** 折叠 thinking 块标签的 spinner 动画;只在 assistant 流式期间开表。 */
63
+ const thinkingDots = new ThinkingDotsAnimator(() => current);
64
+
61
65
  // agent_start → agent_settled 覆盖整轮(含工具调用与重试),空闲时保留上一轮耗时。
62
66
  let turnStartedAt: number | undefined;
63
67
  let lastTurnSeconds: number | undefined;
@@ -81,7 +85,7 @@ export default function (pi: ExtensionAPI) {
81
85
  };
82
86
 
83
87
  /**
84
- * 闪烁帧是构建期快照,换主题后不会自动更新;这里按主题名做一次惰性重建。
88
+ * spinner 帧是构建期快照,换主题后不会自动更新;这里按主题名做一次惰性重建。
85
89
  * 边框标题自己按主题名缓存帧(见 PiUiBorderTitle.currentFrames),不需要这步;
86
90
  * 这里只为内置指示器重新配置,即 working 关闭时的回退路径。
87
91
  */
@@ -90,7 +94,7 @@ export default function (pi: ExtensionAPI) {
90
94
  if (name === syncedTheme) return;
91
95
  syncedTheme = name;
92
96
  if (!enabled.indicator) return;
93
- ctx.ui.setWorkingIndicator({ frames: buildPulseFrames(ctx.ui.theme), intervalMs: PULSE_INTERVAL_MS });
97
+ ctx.ui.setWorkingIndicator({ frames: buildSpinnerFrames(ctx.ui.theme), intervalMs: SPINNER_INTERVAL_MS });
94
98
  };
95
99
 
96
100
  /** 用自己的编辑器子类接管边框标题,让模型名与耗时在空闲时也留在边框上。 */
@@ -100,7 +104,7 @@ export default function (pi: ExtensionAPI) {
100
104
  getModelId: () => modelOverride ?? current?.model?.id,
101
105
  getTimerSeconds: timerSeconds,
102
106
  isStreaming: () => turnStartedAt !== undefined,
103
- isPulseEnabled: () => enabled.indicator,
107
+ isSpinnerEnabled: () => enabled.indicator,
104
108
  getTheme: () => ctx.ui.theme,
105
109
  });
106
110
  return borderEditor;
@@ -121,6 +125,13 @@ export default function (pi: ExtensionAPI) {
121
125
  const apply = (ctx: ExtensionContext, feature: Feature, on: boolean): void => {
122
126
  enabled[feature] = on;
123
127
  switch (feature) {
128
+ case "thinking":
129
+ if (on) thinkingDots.pause();
130
+ else {
131
+ thinkingDots.dispose();
132
+ ctx.ui.setHiddenThinkingLabel(undefined);
133
+ }
134
+ return;
124
135
  case "footer":
125
136
  if (on) installFooter(ctx);
126
137
  else removeFooter(ctx);
@@ -156,6 +167,20 @@ export default function (pi: ExtensionAPI) {
156
167
  if (enabled.working) installBorderEditor(ctx);
157
168
  else removeBorderEditor(ctx);
158
169
  if (enabled.footer) installFooter(ctx);
170
+ if (enabled.thinking) thinkingDots.pause();
171
+ });
172
+
173
+ // spinner 动画跟消息粒度走:一轮里可能有多条 assistant 消息,中间夹着工具调用。
174
+ pi.on("message_start", (event, ctx) => {
175
+ current = ctx;
176
+ if (!enabled.thinking || event.message.role !== "assistant") return;
177
+ thinkingDots.animate();
178
+ });
179
+
180
+ pi.on("message_end", (event, ctx) => {
181
+ current = ctx;
182
+ if (!enabled.thinking || event.message.role !== "assistant") return;
183
+ thinkingDots.pause();
159
184
  });
160
185
 
161
186
  pi.on("model_select", (_event, ctx) => {
@@ -177,11 +202,14 @@ export default function (pi: ExtensionAPI) {
177
202
  lastTurnSeconds = (Date.now() - turnStartedAt) / 1000;
178
203
  turnStartedAt = undefined;
179
204
  }
180
- // 停下呼吸灯,并重绘一次让耗时定格为上一轮结果。
205
+ // 停下 spinner,并重绘一次让耗时定格为上一轮结果。
181
206
  borderEditor?.sync();
207
+ // 用户中断时不会走到 message_end,这里兜住 spinner 动画。
208
+ if (enabled.thinking) thinkingDots.pause();
182
209
  });
183
210
 
184
211
  pi.on("session_shutdown", () => {
212
+ thinkingDots.dispose();
185
213
  borderEditor?.dispose();
186
214
  borderEditor = undefined;
187
215
  footer = undefined;
@@ -190,7 +218,7 @@ export default function (pi: ExtensionAPI) {
190
218
  });
191
219
 
192
220
  pi.registerCommand(COMMAND, {
193
- description: "查看或开关 pi-ui 的 footer、呼吸灯与编辑器边框标题",
221
+ description: "查看或开关 pi-ui 的 footer、盲文 spinner、编辑器边框标题与 thinking spinner 动画",
194
222
  handler: async (args, ctx) => {
195
223
  current = ctx;
196
224
  const [feature, value] = args.trim().toLowerCase().split(/\s+/);
@@ -1,27 +1,33 @@
1
1
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
 
3
- /** 帧间隔,与原版 pi-catppuccin-tui 保持一致。 */
4
- export const PULSE_INTERVAL_MS = 120;
3
+ /**
4
+ * 帧间隔,与 pi 自带 Loader 的 `DEFAULT_INTERVAL_MS` 一致(80ms)。
5
+ *
6
+ * 帧数从 4 变成 10 后,沿用 120ms 会让一圈拖到 1.2s、显得发滞;对齐 pi 的 80ms 才是
7
+ * “原生 spinner” 的手感。
8
+ */
9
+ export const SPINNER_INTERVAL_MS = 80;
5
10
 
6
11
  /**
7
- * 闪烁序列 `· • ● ● • ·`,帧形沿用原版。
12
+ * 盲文 spinner 帧,与 pi 自带 Loader 的 `DEFAULT_FRAMES` 逐帧相同
13
+ * (`@earendil-works/pi-tui` 的 components/loader.js)。
8
14
  *
9
- * 原实现把 Macchiato 的十六进制色写死在帧字符串里,换主题后指示器颜色不会跟着变;
10
- * 这里保留帧形,颜色改为当前主题的 token(dim → accent → borderAccent)。
15
+ * 用 pi 原生那套而不是 `▘▝▗▖` / `◰◳◲◱` 这类整格块:后者在等宽字体下几乎占满整格,
16
+ * 在边框上比正文还抢眼。10 帧都是 1 列宽(pi-tui 的 `visibleWidth`),边框标题不抖。
17
+ */
18
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
19
+
20
+ /**
21
+ * 单色。早先是 `dim → muted → accent → borderAccent` 的明暗递进,那是 4 帧矩形方案
22
+ * 才成立的观感;帧数到 10 后逐帧换色会变成“亮度在扫”,反而不像 pi 自带的 spinner,
23
+ * 所以这里跟 pi 内置 working 指示器一样统一用 `accent`。
11
24
  */
12
- const PULSE: ReadonlyArray<readonly [ThemeColor, string]> = [
13
- ["dim", "·"],
14
- ["dim", "•"],
15
- ["accent", "●"],
16
- ["borderAccent", "●"],
17
- ["muted", "•"],
18
- ["dim", "·"],
19
- ];
25
+ const SPINNER_COLOR: ThemeColor = "accent";
20
26
 
21
27
  /**
22
- * 构建闪烁帧。帧是构建期快照(`theme.fg()` 在此刻求值),
28
+ * 构建 spinner 帧。帧是构建期快照(`theme.fg()` 在此刻求值),
23
29
  * 主题切换后需要重新调用本函数才会生效。
24
30
  */
25
- export function buildPulseFrames(theme: Theme): string[] {
26
- return PULSE.map(([color, glyph]) => theme.fg(color, glyph));
31
+ export function buildSpinnerFrames(theme: Theme): string[] {
32
+ return SPINNER_FRAMES.map((glyph) => theme.fg(SPINNER_COLOR, glyph));
27
33
  }
@@ -0,0 +1,84 @@
1
+ import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { buildSpinnerFrames, SPINNER_INTERVAL_MS } from "./indicator.ts";
4
+
5
+ /** 流式结束后回落的静态标记:横向省略号,明确“已经想完”。 */
6
+ const PAUSED_MARK = "⋯";
7
+
8
+ const LABEL_TEXT = "Thinking...";
9
+
10
+ /**
11
+ * 生成一帧 thinking 标签。`frame` 为 undefined 即空闲帧。
12
+ *
13
+ * 帧直接复用边框指示器那套盲文 spinner(`buildSpinnerFrames`),不在这里再写一份帧表:
14
+ * 两处观感要一致,两份帧列表迟早会漂移。
15
+ *
16
+ * 折叠 thinking 块的标签会被 pi 整串包进 `italic(fg("thinkingText", label))`,
17
+ * 内层再出现 fg 就会把自己的 reset 带进来,之后的行内文本不再跟随 `thinkingText`;
18
+ * 所以尾部文本这里主动再套一次 `thinkingText`,颜色不塌。
19
+ */
20
+ function buildLabel(theme: Theme, frame: string | undefined): string {
21
+ const head = frame ?? theme.fg("dim", PAUSED_MARK);
22
+ return `${head} ${theme.fg("thinkingText", LABEL_TEXT)}`;
23
+ }
24
+
25
+ /**
26
+ * 折叠 thinking 块标签的 spinner 动画。
27
+ *
28
+ * pi 只提供静态的 `setHiddenThinkingLabel`,动画靠定时器反复改写标签实现;每次改写会
29
+ * 重绘整条 transcript,所以只在 assistant 消息流式期间开表,消息结束立刻停表并回落到
30
+ * 静态帧——流式期间 pi 本来就在逐 token 重绘,这点额外开销看不出来。
31
+ * 频率跟边框指示器一致(`SPINNER_INTERVAL_MS`),两处 spinner 因此同速。
32
+ *
33
+ * 会话对象会在切换/重载时被替换,所以上下文用取值函数传入而不是构造期捕获。
34
+ */
35
+ export class ThinkingDotsAnimator {
36
+ private readonly getContext: () => ExtensionContext | undefined;
37
+ private timer: ReturnType<typeof setInterval> | undefined;
38
+ private step = 0;
39
+
40
+ constructor(getContext: () => ExtensionContext | undefined) {
41
+ this.getContext = getContext;
42
+ }
43
+
44
+ /** 回落静态帧。每次进空闲都重新取色,主题在两轮之间被换掉也不会留下旧颜色。 */
45
+ pause(): void {
46
+ this.stopTimer();
47
+ this.paint(undefined);
48
+ }
49
+
50
+ /** 开始流式动画。已在动就不重启,避免每个 `message_start` 都重置相位。 */
51
+ animate(): void {
52
+ if (this.timer) return;
53
+ this.step = 0;
54
+ this.paint(this.frameAt(0));
55
+ this.timer = setInterval(() => {
56
+ this.step += 1;
57
+ this.paint(this.frameAt(this.step));
58
+ }, SPINNER_INTERVAL_MS);
59
+ }
60
+
61
+ dispose(): void {
62
+ this.stopTimer();
63
+ }
64
+
65
+ /** 帧是构建期快照(`theme.fg()` 当场求值),所以每帧按当前主题重取一次。 */
66
+ private frameAt(step: number): string | undefined {
67
+ const context = this.getContext();
68
+ if (!context?.hasUI) return undefined;
69
+ const frames = buildSpinnerFrames(context.ui.theme);
70
+ return frames[step % frames.length];
71
+ }
72
+
73
+ private paint(frame: string | undefined): void {
74
+ const context = this.getContext();
75
+ if (!context?.hasUI) return;
76
+ context.ui.setHiddenThinkingLabel(buildLabel(context.ui.theme, frame));
77
+ }
78
+
79
+ private stopTimer(): void {
80
+ if (!this.timer) return;
81
+ clearInterval(this.timer);
82
+ this.timer = undefined;
83
+ }
84
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@muzi3/pi-ui",
3
- "version": "0.1.1",
4
- "description": "Pi extension: 双行仪表盘 footer + 主题化工作指示器 + Catppuccin Mocha 层次增强主题",
3
+ "version": "0.2.0",
4
+ "description": "Pi extension: 双行仪表盘 footer + 主题化工作指示器 + 折叠 thinking 标签三点动画 + Catppuccin Mocha 层次增强主题",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-extension",
@@ -79,8 +79,8 @@
79
79
  "syntaxType": "yellow",
80
80
  "syntaxOperator": "sky",
81
81
  "syntaxPunctuation": "overlay2",
82
- "thinkingOff": "surface1",
83
- "thinkingMinimal": "surface2",
82
+ "thinkingOff": "overlay0",
83
+ "thinkingMinimal": "overlay1",
84
84
  "thinkingLow": "blue",
85
85
  "thinkingMedium": "sapphire",
86
86
  "thinkingHigh": "mauve",