@caph42/pi-ccline 0.2.1 → 0.2.2
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 +4 -1
- package/README_zh.md +4 -1
- package/extensions/pi-ccline/config.test.ts +50 -1
- package/extensions/pi-ccline/config.ts +84 -0
- package/extensions/pi-ccline/footer.test.ts +28 -1
- package/extensions/pi-ccline/index.ts +9 -2
- package/extensions/pi-ccline/render.test.ts +57 -0
- package/extensions/pi-ccline/render.ts +38 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,7 +77,8 @@ After startup the footer is whatever ccline renders. Your terminal needs Nerd Fo
|
|
|
77
77
|
"refreshThrottleMs": 1000,
|
|
78
78
|
"liveRefreshMs": 1000,
|
|
79
79
|
"showProvider": true,
|
|
80
|
-
"env": {}
|
|
80
|
+
"env": {},
|
|
81
|
+
"statusBadges": {}
|
|
81
82
|
}
|
|
82
83
|
```
|
|
83
84
|
|
|
@@ -90,6 +91,7 @@ After startup the footer is whatever ccline renders. Your terminal needs Nerd Fo
|
|
|
90
91
|
| `liveRefreshMs` | `1000` | Interval of the timer that refreshes while the agent runs (so `duration` ticks every second); `0` disables the timer and leaves only event-driven refreshes. When non-zero, anything below 250 is raised to 250 |
|
|
91
92
|
| `showProvider` | `true` | Whether to append the provider to the model segment, e.g. `Sonnet 4.5 (anthropic)`; `false` shows just the model name. If the terminal is narrow enough for ccline to truncate the model segment, the provider may not be visible |
|
|
92
93
|
| `env` | `{}` | Extra environment variables for ccline; same-named variables override the values inherited from pi (except `COLUMNS`, which the extension writes from the current width) |
|
|
94
|
+
| `statusBadges` | `{}` | Icons and colors for the status row other extensions set through `ctx.ui.setStatus`, shaped `{"<status key>": {"icon": "<glyph>", "color": "<theme token>"}}`. Built in: `pi-herdr-pstack` (its `poteto-mode` status) → the Nerd Font rocket `U+F135` in `accent`. Fields merge over the built-in entry, so `{"pi-herdr-pstack": {"icon": ""}}` drops the icon and keeps the color; `""` for both removes the badge. Status keys are matched verbatim against `setStatus` keys. A status whose text already carries ANSI colors is left untouched, and an unknown theme token falls back to uncolored text |
|
|
93
95
|
|
|
94
96
|
Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccline` on `PATH`.
|
|
95
97
|
|
|
@@ -102,6 +104,7 @@ Binary resolution order: `config.binary` → `~/.claude/ccline/ccline` → `ccli
|
|
|
102
104
|
- Binary not found: the footer shows a one-line install hint.
|
|
103
105
|
- Widths are always computed with `visibleWidth` / `truncateToWidth`, never with `.length`.
|
|
104
106
|
- **Other extensions' `ctx.ui.setStatus` stays visible**: `render(width)` reads `footerData.getExtensionStatuses()`, sorts by key, joins them with spaces into one line, truncates with a dim `…` when too wide, and appends it below the ccline frame, matching pi's built-in footer. It reads the live map and `setStatus` itself triggers a repaint, so status changes are not limited by the 1Hz throttle.
|
|
107
|
+
- **A status key can carry a badge**: `setStatus` hands over text, and whether it is styled depends on the extension that sends it (pi's own footer adds nothing). This footer therefore decorates only statuses that arrive unstyled — a Nerd Font icon and a theme color, per status key (`DEFAULT_STATUS_BADGES` in `config.ts` holds the built-in entry for pi-herdr-pstack's `poteto-mode`). The icon is counted with the rest of the row before truncating, a status whose text already contains ANSI is skipped to avoid mixing two palettes, and the color goes through one `theme.fg()` call per badge that degrades to plain text when the token is not in the theme.
|
|
105
108
|
- Because there is only one footer slot globally, other extensions' custom footers (e.g. pi-cursor-provider's fast badge) get displaced once this extension is installed; their status lines still show. Conversely, when they take over, the ccline frame disappears and `/pi-ccline on` takes it back.
|
|
106
109
|
- **The provider can only be folded into the model segment.** ccline 2.0.1's input schema has no provider field and the `model` segment renders only `display_name`, so the provider is appended after the model name as `name (provider)`; it isn't appended twice when the name already carries the same suffix. `showProvider: false` turns it off. **At narrow widths the provider may be invisible**: ccline wraps/truncates by `COLUMNS` itself, and in practice at width 40 the model segment is left with just `GPT-5.6` — that's upstream layout behaviour, and this extension only guarantees the provider is written into `display_name`.
|
|
107
110
|
- The input JSON **omits the `version` field**: ccline 2.0.1 validates it as a Claude Code version and rejects pi's version outright.
|
package/README_zh.md
CHANGED
|
@@ -77,7 +77,8 @@ pi install .
|
|
|
77
77
|
"refreshThrottleMs": 1000,
|
|
78
78
|
"liveRefreshMs": 1000,
|
|
79
79
|
"showProvider": true,
|
|
80
|
-
"env": {}
|
|
80
|
+
"env": {},
|
|
81
|
+
"statusBadges": {}
|
|
81
82
|
}
|
|
82
83
|
```
|
|
83
84
|
|
|
@@ -90,6 +91,7 @@ pi install .
|
|
|
90
91
|
| `liveRefreshMs` | `1000` | agent 运行期间的定时刷新间隔(让 `耗时` 逐秒走);`0` 关闭定时器,只保留事件刷新。非 0 时低于 250 会被抬到 250 |
|
|
91
92
|
| `showProvider` | `true` | 是否在模型段追加 provider,如 `Sonnet 4.5 (anthropic)`;`false` 只显示模型名。终端窄到模型段被 ccline 截断时 provider 可能不可见 |
|
|
92
93
|
| `env` | `{}` | 追加给 ccline 的环境变量;同名变量覆盖 pi 继承的值(`COLUMNS` 除外,它由扩展按当前宽度写入) |
|
|
94
|
+
| `statusBadges` | `{}` | 给其他扩展 `ctx.ui.setStatus` 那一行加图标和颜色,形状 `{"<status key>": {"icon": "<字形>", "color": "<主题 token>"}}`。内置一条:`pi-herdr-pstack`(它的 `poteto-mode` 状态)→ Nerd Font 火箭 `U+F135` + `accent`。字段按项合并到内置条目上,所以 `{"pi-herdr-pstack": {"icon": ""}}` 只去掉图标、保留颜色;两个字段都写 `""` 即去掉徽标。status key 与 `setStatus` 的 key 逐字匹配;文本本身已带 ANSI 颜色的状态原样保留;主题里不存在的 token 回退成不上色 |
|
|
93
95
|
|
|
94
96
|
二进制解析顺序:`config.binary` → `~/.claude/ccline/ccline` → `PATH` 中的 `ccline`。
|
|
95
97
|
|
|
@@ -102,6 +104,7 @@ pi install .
|
|
|
102
104
|
- 找不到二进制:footer 显示一行安装提示。
|
|
103
105
|
- 行宽一律用 `visibleWidth` / `truncateToWidth` 计算,不用 `.length`。
|
|
104
106
|
- **其他扩展的 `ctx.ui.setStatus` 照样可见**:`render(width)` 读 `footerData.getExtensionStatuses()`,按 key 排序、用空格拼成一行、超宽时用 dim `…` 截断,追加在 ccline 帧下方——行为对齐 pi 内置 footer。读的是实时 map,`setStatus` 自己会触发重绘,所以状态变化不受 1Hz 节流限制。
|
|
107
|
+
- **status 可以带徽标**:`setStatus` 交过来的是文本,有没有上色取决于发它的扩展(pi 自带 footer 不再加任何样式)。所以本扩展只装饰「没有自己上色」的状态:按 status key 加 Nerd Font 图标和主题色(内置条目在 `config.ts` 的 `DEFAULT_STATUS_BADGES`,目前是 pi-herdr-pstack 的 `poteto-mode`)。图标计入整行的可见宽度再截断;文本自带 ANSI 的状态跳过装饰,避免两套配色混在一起;颜色每帧走一次 `theme.fg()`,token 不在主题里就退成纯文本。
|
|
105
108
|
- 因为 footer 槽位全局只有一个,本扩展装上后其他扩展的自定义 footer(例如 pi-cursor-provider 的 fast 徽标)会被顶掉;它们的 status 行仍会显示。反过来它们抢占时 ccline 帧会消失,需要 `/pi-ccline on` 取回。
|
|
106
109
|
- **provider 只能拼进模型段。** ccline 2.0.1 的输入 schema 没有 provider 字段,`model` 段只渲染 `display_name`,所以 provider 以 `名称 (provider)` 的形式追加在模型名后;名字里已经带同样的后缀时不重复追加。`showProvider: false` 可关掉。**窄宽度下 provider 可能看不到**:ccline 按 `COLUMNS` 自己折行/截断,实测宽度 40 时模型段只剩 `GPT-5.6`,这是上游布局行为,本扩展只保证把 provider 写进 `display_name`。
|
|
107
110
|
- 输入 JSON **省略 `version` 字段**:ccline 2.0.1 会把它当 Claude Code 版本校验,传 pi 的版本会被直接拒绝。
|
|
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { test } from "node:test";
|
|
6
6
|
|
|
7
|
-
import { DEFAULT_CONFIG, MIN_LIVE_REFRESH_MS, MIN_THROTTLE_MS, configPath, loadConfig, normalizeConfig, saveConfig } from "./config.ts";
|
|
7
|
+
import { DEFAULT_CONFIG, DEFAULT_STATUS_BADGES, MIN_LIVE_REFRESH_MS, MIN_THROTTLE_MS, configPath, loadConfig, normalizeConfig, resolveStatusBadges, saveConfig } from "./config.ts";
|
|
8
8
|
|
|
9
9
|
function tempDir(): string {
|
|
10
10
|
return mkdtempSync(join(tmpdir(), "pi-ccline-config-"));
|
|
@@ -53,9 +53,58 @@ test("keeps valid overrides", () => {
|
|
|
53
53
|
liveRefreshMs: DEFAULT_CONFIG.liveRefreshMs,
|
|
54
54
|
showProvider: DEFAULT_CONFIG.showProvider,
|
|
55
55
|
env: {},
|
|
56
|
+
statusBadges: {},
|
|
56
57
|
});
|
|
57
58
|
});
|
|
58
59
|
|
|
60
|
+
test("ships a built-in poteto-mode badge and lets the config override it field by field", () => {
|
|
61
|
+
assert.deepEqual(DEFAULT_CONFIG.statusBadges, {});
|
|
62
|
+
assert.deepEqual(resolveStatusBadges(undefined), DEFAULT_STATUS_BADGES);
|
|
63
|
+
|
|
64
|
+
// Clearing only the icon keeps the built-in color.
|
|
65
|
+
const noIcon = normalizeConfig({ statusBadges: { "pi-herdr-pstack": { icon: "" } } });
|
|
66
|
+
assert.deepEqual(resolveStatusBadges(noIcon.statusBadges), { "pi-herdr-pstack": { icon: "", color: "accent" } });
|
|
67
|
+
|
|
68
|
+
// A color-only override reuses the built-in icon.
|
|
69
|
+
const recolored = normalizeConfig({ statusBadges: { "pi-herdr-pstack": { color: "warning" } } });
|
|
70
|
+
assert.deepEqual(resolveStatusBadges(recolored.statusBadges), { "pi-herdr-pstack": { icon: DEFAULT_STATUS_BADGES["pi-herdr-pstack"]?.icon, color: "warning" } });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("badges can be added for other status keys, and unusable entries are dropped", () => {
|
|
74
|
+
const config = normalizeConfig({
|
|
75
|
+
statusBadges: {
|
|
76
|
+
"ssh-remote": { color: "success" },
|
|
77
|
+
" ssh-remote ": { color: "error" },
|
|
78
|
+
["x".repeat(65)]: { icon: "y" },
|
|
79
|
+
mcp: { icon: "\n\t" },
|
|
80
|
+
broken: 5,
|
|
81
|
+
"": { icon: "x" },
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
// Keys are matched verbatim against `setStatus` keys, so a padded or oversized
|
|
85
|
+
// key is dropped rather than rewritten into something that can only miss.
|
|
86
|
+
assert.deepEqual(config.statusBadges, { "ssh-remote": { color: "success" } });
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("prototype-named keys stay own data properties", () => {
|
|
90
|
+
// Only JSON can carry an own `__proto__` key; an object literal would set the prototype.
|
|
91
|
+
const raw = JSON.parse('{"statusBadges":{"constructor":{"icon":"C"},"__proto__":{"color":"error"}}}');
|
|
92
|
+
const config = normalizeConfig(raw);
|
|
93
|
+
const resolved = resolveStatusBadges(config.statusBadges);
|
|
94
|
+
assert.equal(Object.hasOwn(resolved, "constructor"), true);
|
|
95
|
+
assert.equal(Object.hasOwn(resolved, "__proto__"), true);
|
|
96
|
+
assert.equal(Object.getPrototypeOf(resolved), Object.prototype);
|
|
97
|
+
assert.deepEqual(resolved.constructor, { icon: "C", color: "" });
|
|
98
|
+
assert.deepEqual(resolved["__proto__"], { icon: "", color: "error" });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("a badge icon is cleaned and capped instead of breaking the status row", () => {
|
|
102
|
+
const [icon = ""] = Object.values(normalizeConfig({ statusBadges: { k: { icon: " \u001b[31mabcde " } } }).statusBadges);
|
|
103
|
+
assert.deepEqual(icon, { icon: "abcd" });
|
|
104
|
+
assert.deepEqual(normalizeConfig({ statusBadges: [] }).statusBadges, {});
|
|
105
|
+
assert.deepEqual(normalizeConfig({ statusBadges: "none" }).statusBadges, {});
|
|
106
|
+
});
|
|
107
|
+
|
|
59
108
|
test("showProvider defaults on and ignores non-boolean values", () => {
|
|
60
109
|
assert.equal(DEFAULT_CONFIG.showProvider, true);
|
|
61
110
|
assert.equal(normalizeConfig({}).showProvider, true);
|
|
@@ -20,6 +20,12 @@ export interface PiCclineConfig {
|
|
|
20
20
|
showProvider: boolean;
|
|
21
21
|
/** Extra environment variables passed to the binary. */
|
|
22
22
|
env: Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* Per status-key badges for other extensions' `ctx.ui.setStatus` rows, merged
|
|
25
|
+
* over `DEFAULT_STATUS_BADGES` at render time. Only the fields a user set are
|
|
26
|
+
* stored, so a newer built-in default still applies when a field is absent.
|
|
27
|
+
*/
|
|
28
|
+
statusBadges: Record<string, StatusBadgeOverride>;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
export const DEFAULT_CONFIG: PiCclineConfig = {
|
|
@@ -30,8 +36,84 @@ export const DEFAULT_CONFIG: PiCclineConfig = {
|
|
|
30
36
|
liveRefreshMs: 1000,
|
|
31
37
|
showProvider: true,
|
|
32
38
|
env: {},
|
|
39
|
+
statusBadges: {},
|
|
33
40
|
};
|
|
34
41
|
|
|
42
|
+
/** A resolved badge: a Nerd Font glyph in front of the text and a theme color. */
|
|
43
|
+
export interface StatusBadge {
|
|
44
|
+
/** Glyph shown before the status text; empty for none. */
|
|
45
|
+
icon: string;
|
|
46
|
+
/** pi theme foreground token (`accent`, `success`, …); empty or unknown for no color. */
|
|
47
|
+
color: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What a user may write per key: either field may be omitted and inherited. */
|
|
51
|
+
export type StatusBadgeOverride = Partial<StatusBadge>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Built-in badges for statuses set by packages this footer knows about. The
|
|
55
|
+
* supplying extension sends text (and may style it itself); this footer adds an
|
|
56
|
+
* icon and a color when it left the text plain. Keys are pi's status keys,
|
|
57
|
+
* matched verbatim, never the package name.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_STATUS_BADGES: Record<string, StatusBadge> = {
|
|
60
|
+
// pi-herdr-pstack keeps this key while the sticky poteto methodology is on.
|
|
61
|
+
"pi-herdr-pstack": { icon: "\uf135", color: "accent" },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** An icon longer than this is a typo or a whole path, not a glyph. */
|
|
65
|
+
const MAX_BADGE_ICON_CODEPOINTS = 4;
|
|
66
|
+
const MAX_BADGE_KEY_LENGTH = 64;
|
|
67
|
+
|
|
68
|
+
/** A badge is text: drop CSI/OSC escapes, then any remaining control character. */
|
|
69
|
+
const ESCAPE_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
|
|
70
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/g;
|
|
71
|
+
|
|
72
|
+
function cleanBadgeText(value: string): string {
|
|
73
|
+
return value.replace(ESCAPE_SEQUENCE, "").replace(CONTROL_CHARACTERS, "").trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizeBadges(raw: unknown): Record<string, StatusBadgeOverride> {
|
|
77
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
|
78
|
+
const badges: [string, StatusBadgeOverride][] = [];
|
|
79
|
+
for (const [key, rawBadge] of Object.entries(raw as Record<string, unknown>)) {
|
|
80
|
+
// Status keys are matched verbatim against `setStatus` keys: a padded or
|
|
81
|
+
// oversized key can only ever miss, so it is dropped instead of rewritten.
|
|
82
|
+
if (!key || key !== key.trim() || key.length > MAX_BADGE_KEY_LENGTH) continue;
|
|
83
|
+
if (!rawBadge || typeof rawBadge !== "object" || Array.isArray(rawBadge)) continue;
|
|
84
|
+
const entry = rawBadge as Record<string, unknown>;
|
|
85
|
+
const badge: StatusBadgeOverride = {};
|
|
86
|
+
if (typeof entry.icon === "string") {
|
|
87
|
+
badge.icon = Array.from(cleanBadgeText(entry.icon)).slice(0, MAX_BADGE_ICON_CODEPOINTS).join("");
|
|
88
|
+
}
|
|
89
|
+
if (typeof entry.color === "string") badge.color = cleanBadgeText(entry.color);
|
|
90
|
+
if (badge.icon === undefined && badge.color === undefined) continue;
|
|
91
|
+
// An entry that ends up empty is how a built-in badge is cleared; for an
|
|
92
|
+
// unknown key it would only add noise.
|
|
93
|
+
const emptied = !badge.icon && !badge.color;
|
|
94
|
+
if (emptied && !Object.hasOwn(DEFAULT_STATUS_BADGES, key)) continue;
|
|
95
|
+
badges.push([key, badge]);
|
|
96
|
+
}
|
|
97
|
+
// fromEntries keeps keys like `__proto__` as own data properties instead of
|
|
98
|
+
// letting the assignment rewrite the prototype.
|
|
99
|
+
return Object.fromEntries(badges);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Built-in badges with the user's per-field overrides applied. */
|
|
103
|
+
export function resolveStatusBadges(
|
|
104
|
+
overrides: Readonly<Record<string, StatusBadgeOverride>> | null | undefined,
|
|
105
|
+
): Record<string, StatusBadge> {
|
|
106
|
+
const resolved = new Map<string, StatusBadge>();
|
|
107
|
+
for (const [key, badge] of Object.entries(DEFAULT_STATUS_BADGES)) resolved.set(key, { ...badge });
|
|
108
|
+
for (const [key, override] of Object.entries(overrides ?? {})) {
|
|
109
|
+
const merged: StatusBadge = { ...(resolved.get(key) ?? { icon: "", color: "" }) };
|
|
110
|
+
if (override.icon !== undefined) merged.icon = override.icon;
|
|
111
|
+
if (override.color !== undefined) merged.color = override.color;
|
|
112
|
+
resolved.set(key, merged);
|
|
113
|
+
}
|
|
114
|
+
return Object.fromEntries(resolved);
|
|
115
|
+
}
|
|
116
|
+
|
|
35
117
|
export const MIN_TIMEOUT_MS = 200;
|
|
36
118
|
export const MAX_TIMEOUT_MS = 60_000;
|
|
37
119
|
/** The footer re-renders often; a faster refresh rate would spawn too many processes. */
|
|
@@ -69,6 +151,7 @@ function clampLiveRefresh(value: unknown, fallback: number): number {
|
|
|
69
151
|
}
|
|
70
152
|
|
|
71
153
|
const binary = typeof source.binary === "string" && source.binary.trim() ? source.binary.trim() : null;
|
|
154
|
+
const statusBadges = normalizeBadges(source.statusBadges);
|
|
72
155
|
|
|
73
156
|
return {
|
|
74
157
|
enabled: typeof source.enabled === "boolean" ? source.enabled : DEFAULT_CONFIG.enabled,
|
|
@@ -78,6 +161,7 @@ const binary = typeof source.binary === "string" && source.binary.trim() ? sourc
|
|
|
78
161
|
liveRefreshMs: clampLiveRefresh(source.liveRefreshMs, DEFAULT_CONFIG.liveRefreshMs),
|
|
79
162
|
showProvider: typeof source.showProvider === "boolean" ? source.showProvider : DEFAULT_CONFIG.showProvider,
|
|
80
163
|
env,
|
|
164
|
+
statusBadges,
|
|
81
165
|
};
|
|
82
166
|
}
|
|
83
167
|
|
|
@@ -7,7 +7,7 @@ import { afterEach, test } from "node:test";
|
|
|
7
7
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
|
|
9
9
|
import { clearBinaryCache } from "./binary.ts";
|
|
10
|
-
import { DEFAULT_CONFIG, type PiCclineConfig } from "./config.ts";
|
|
10
|
+
import { DEFAULT_CONFIG, DEFAULT_STATUS_BADGES, type PiCclineConfig } from "./config.ts";
|
|
11
11
|
import { CclineFooter, default as piCcline } from "./index.ts";
|
|
12
12
|
|
|
13
13
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -198,6 +198,33 @@ test("shows extension statuses even before ccline has produced a frame", () => {
|
|
|
198
198
|
harness.footer.dispose();
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
test("the built-in poteto-mode badge decorates the pi-herdr-pstack status", () => {
|
|
202
|
+
// The fake theme wraps everything in dim, so the marker proves the badge color
|
|
203
|
+
// path ran through theme.fg rather than being pasted on unconditionally.
|
|
204
|
+
const statuses = new Map([
|
|
205
|
+
["pi-herdr-pstack", "poteto-mode"],
|
|
206
|
+
["other-plugin", "Other status"],
|
|
207
|
+
]);
|
|
208
|
+
const harness = attach({ binary: fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'") }, undefined, statuses);
|
|
209
|
+
const [statusRow] = harness.render(80).slice(1);
|
|
210
|
+
assert.equal(statusRow?.replace(/\u001b\[[0-9;]*m/g, ""), `Other status ${DEFAULT_STATUS_BADGES["pi-herdr-pstack"]?.icon} poteto-mode`);
|
|
211
|
+
assert.match(statusRow ?? "", /\u001b\[2m/, "the badge is colored through the theme");
|
|
212
|
+
harness.footer.dispose();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("a configured badge override reaches the rendered row", () => {
|
|
216
|
+
const statuses = new Map([["pi-herdr-pstack", "poteto-mode"]]);
|
|
217
|
+
const harness = attach(
|
|
218
|
+
{ binary: fakeBinary("cat > /dev/null; printf 'FAKE-CCLINE\\n'"), statusBadges: { "pi-herdr-pstack": { icon: "" } } },
|
|
219
|
+
undefined,
|
|
220
|
+
statuses,
|
|
221
|
+
);
|
|
222
|
+
const row = harness.render(80)[1] ?? "";
|
|
223
|
+
assert.equal(row.replace(/\u001b\[[0-9;]*m/g, ""), "poteto-mode");
|
|
224
|
+
assert.match(row, /\u001b\[2m/, "clearing the icon keeps the configured color");
|
|
225
|
+
harness.footer.dispose();
|
|
226
|
+
});
|
|
227
|
+
|
|
201
228
|
test("live ticking advances time-based segments and stops with the agent run", async () => {
|
|
202
229
|
const counter = join(tempDir("pi-ccline-live-"), "count");
|
|
203
230
|
const binary = fakeBinary(
|
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
|
|
4
4
|
import { clearBinaryCache, probeVersion, resolveBinary, spawnCcline, type ResolvedBinary } from "./binary.ts";
|
|
5
|
-
import { loadConfig, saveConfig, type PiCclineConfig } from "./config.ts";
|
|
5
|
+
import { loadConfig, resolveStatusBadges, saveConfig, type PiCclineConfig, type StatusBadge } from "./config.ts";
|
|
6
6
|
import { buildCclineInput, collectSnapshot } from "./input.ts";
|
|
7
7
|
import { RefreshScheduler, RefreshTicker } from "./refresh.ts";
|
|
8
8
|
import { extensionStatusLine, loadingNotice, missingBinaryNotice, normalizeFrame, noticeFrame, withStaleMarker } from "./render.ts";
|
|
@@ -57,6 +57,8 @@ function firstLine(text: string): string {
|
|
|
57
57
|
export class CclineFooter {
|
|
58
58
|
private readonly ctx: ExtensionContext;
|
|
59
59
|
private readonly config: PiCclineConfig;
|
|
60
|
+
/** Built-in badges with the user's overrides already merged, resolved once. */
|
|
61
|
+
private readonly badges: Record<string, StatusBadge>;
|
|
60
62
|
private readonly scheduler: RefreshScheduler;
|
|
61
63
|
/** Only runs while the agent is active so an idle session spawns nothing. */
|
|
62
64
|
private readonly ticker: RefreshTicker;
|
|
@@ -91,6 +93,7 @@ export class CclineFooter {
|
|
|
91
93
|
constructor(ctx: ExtensionContext, config: PiCclineConfig) {
|
|
92
94
|
this.ctx = ctx;
|
|
93
95
|
this.config = config;
|
|
96
|
+
this.badges = resolveStatusBadges(config.statusBadges);
|
|
94
97
|
this.scheduler = new RefreshScheduler({
|
|
95
98
|
throttleMs: config.refreshThrottleMs,
|
|
96
99
|
task: () => this.refresh(),
|
|
@@ -283,7 +286,11 @@ export class CclineFooter {
|
|
|
283
286
|
return null;
|
|
284
287
|
}
|
|
285
288
|
})();
|
|
286
|
-
const status = extensionStatusLine(statuses, safeWidth, this.theme ? this.theme.fg("dim", "…") : "…"
|
|
289
|
+
const status = extensionStatusLine(statuses, safeWidth, this.theme ? this.theme.fg("dim", "…") : "…", {
|
|
290
|
+
badges: this.badges,
|
|
291
|
+
// render.ts catches a throwing token, so a stale theme entry costs only its color.
|
|
292
|
+
colorize: this.theme ? (color, text) => this.theme?.fg(color, text) ?? text : null,
|
|
293
|
+
});
|
|
287
294
|
return status ? [...lines, status] : lines;
|
|
288
295
|
}
|
|
289
296
|
|
|
@@ -89,3 +89,60 @@ test("a long status row is truncated by visible width with the dim ellipsis", ()
|
|
|
89
89
|
assert.ok(visibleWidth(row) <= 10, `visible width was ${visibleWidth(row ?? "")}`);
|
|
90
90
|
assert.ok((row ?? "").replace(/\u001b\[[0-9;]*m/g, "").endsWith("..."));
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
test("a badge adds its icon and color to the matching status key only", () => {
|
|
94
|
+
const statuses = new Map([
|
|
95
|
+
["mcp", "connected"],
|
|
96
|
+
["pi-herdr-pstack", "poteto-mode"],
|
|
97
|
+
]);
|
|
98
|
+
const line = extensionStatusLine(statuses, 80, "…", {
|
|
99
|
+
badges: { "pi-herdr-pstack": { icon: "R", color: "accent" } },
|
|
100
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
101
|
+
});
|
|
102
|
+
assert.equal(line, "connected [accent]R poteto-mode");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a status that colors itself keeps its own styling", () => {
|
|
106
|
+
const statuses = new Map([["pi-herdr-pstack", `${RED}poteto-mode${RESET}`]]);
|
|
107
|
+
const line = extensionStatusLine(statuses, 80, "…", {
|
|
108
|
+
badges: { "pi-herdr-pstack": { icon: "R", color: "accent" } },
|
|
109
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
110
|
+
});
|
|
111
|
+
assert.equal(line, `${RED}poteto-mode${RESET}`);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("an unknown theme token falls back to the plain badge label", () => {
|
|
115
|
+
const line = extensionStatusLine(new Map([["k", "v"]]), 80, "…", {
|
|
116
|
+
badges: { k: { icon: "R", color: "nope" } },
|
|
117
|
+
colorize: () => {
|
|
118
|
+
throw new Error("Unknown theme color: nope");
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
assert.equal(line, "R v");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("an icon with no color still shows, and an empty icon adds no gap", () => {
|
|
125
|
+
const badges = { a: { icon: "R" }, b: { icon: "", color: "accent" } };
|
|
126
|
+
const line = extensionStatusLine(new Map([["a", "one"], ["b", "two"]]), 80, "…", {
|
|
127
|
+
badges,
|
|
128
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
129
|
+
});
|
|
130
|
+
assert.equal(line, "R one [accent]two");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("only own badge entries are applied, never inherited object members", () => {
|
|
134
|
+
const line = extensionStatusLine(new Map([["toString", "v"]]), 80, "…", {
|
|
135
|
+
badges: {},
|
|
136
|
+
colorize: (color, text) => `[${color}]${text}`,
|
|
137
|
+
});
|
|
138
|
+
assert.equal(line, "v");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("the badge counts toward the truncated width", () => {
|
|
142
|
+
const row = extensionStatusLine(new Map([["k", "日".repeat(10)]]), 10, "...", {
|
|
143
|
+
badges: { k: { icon: "R", color: "accent" } },
|
|
144
|
+
colorize: (color, text) => `\u001b[35m${text}\u001b[39m`,
|
|
145
|
+
});
|
|
146
|
+
assert.ok(row !== null);
|
|
147
|
+
assert.ok(visibleWidth(row) <= 10, `visible width was ${visibleWidth(row ?? "")}`);
|
|
148
|
+
});
|
|
@@ -35,6 +35,40 @@ export function sanitizeStatusText(text: string): string {
|
|
|
35
35
|
return text.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim();
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** The badge fields this module needs; config.ts owns the validated shape. */
|
|
39
|
+
export type StatusBadgeLike = { icon?: string | undefined; color?: string | undefined };
|
|
40
|
+
|
|
41
|
+
export interface StatusLineOptions {
|
|
42
|
+
/** Badges per status key; a key without an entry stays plain text. */
|
|
43
|
+
badges?: Readonly<Record<string, StatusBadgeLike>> | null | undefined;
|
|
44
|
+
/** Theme colorizer for the badge color token; missing or throwing means plain text. */
|
|
45
|
+
colorize?: ((color: string, text: string) => string) | null | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A status arrives from another extension as text, and that extension may or
|
|
50
|
+
* may not have colored it. A badge adds an icon and a color — but only to a
|
|
51
|
+
* status that did not style itself, so an extension that already uses
|
|
52
|
+
* `theme.fg` keeps its own palette instead of ending up half-colored.
|
|
53
|
+
*/
|
|
54
|
+
function withBadge(key: string, text: string, options: StatusLineOptions): string {
|
|
55
|
+
const badges = options.badges;
|
|
56
|
+
// Own properties only: a status key like `toString` must not resolve to the
|
|
57
|
+
// object's inherited members.
|
|
58
|
+
const badge = badges && Object.hasOwn(badges, key) ? badges[key] : undefined;
|
|
59
|
+
if (!badge) return text;
|
|
60
|
+
if (text.includes("\u001b[")) return text;
|
|
61
|
+
const label = badge.icon ? `${badge.icon} ${text}` : text;
|
|
62
|
+
const colorize = options.colorize;
|
|
63
|
+
if (!badge.color || !colorize) return label;
|
|
64
|
+
try {
|
|
65
|
+
return colorize(badge.color, label);
|
|
66
|
+
} catch {
|
|
67
|
+
// An unknown theme token must not cost the row its text.
|
|
68
|
+
return label;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
38
72
|
/**
|
|
39
73
|
* pi's built-in footer puts every extension status on one extra row, sorted by
|
|
40
74
|
* key and joined by a space. Mirror that so `ctx.ui.setStatus` keeps working
|
|
@@ -44,12 +78,14 @@ export function extensionStatusLine(
|
|
|
44
78
|
statuses: Iterable<[string, string]> | null | undefined,
|
|
45
79
|
width: number,
|
|
46
80
|
ellipsis = "…",
|
|
81
|
+
options: StatusLineOptions = {},
|
|
47
82
|
): string | null {
|
|
48
83
|
if (!statuses) return null;
|
|
49
84
|
const text = [...statuses]
|
|
50
85
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
51
|
-
.map(([, value]) => sanitizeStatusText(value))
|
|
52
|
-
.filter((
|
|
86
|
+
.map(([key, value]) => ({ key, text: sanitizeStatusText(value) }))
|
|
87
|
+
.filter((status) => status.text.length > 0)
|
|
88
|
+
.map((status) => withBadge(status.key, status.text, options))
|
|
53
89
|
.join(" ");
|
|
54
90
|
if (!text) return null;
|
|
55
91
|
const safeWidth = Math.max(1, Math.floor(width));
|