rinco-pi-hud 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,6 +20,8 @@ Rinco Pi HUD takes over Pi's footer with four groups of live state and lets you
20
20
 
21
21
  ## Install
22
22
 
23
+ Requires Pi **1.3.0 or newer within the 1.x release line**. Development and CI dependencies are pinned to 1.3.0.
24
+
23
25
  ```bash
24
26
  pi install npm:rinco-pi-hud
25
27
  ```
@@ -42,7 +44,7 @@ pi install "$PWD"
42
44
  > Pi extensions run with your user permissions. Review the source before installing any third-party extension.
43
45
 
44
46
  > [!NOTE]
45
- > The footer needs a Truecolor terminal. The screenshot above renders with ASCII icon mode; `/zentui` switches between the `auto`, `nerd`, and `ascii` glyph sets.
47
+ > Truecolor and 256-color terminals are supported; explicit RGB colors use Pi's terminal-capability-aware encoder. The screenshot above renders with ASCII icon mode; `/zentui` switches between the `auto`, `nerd`, and `ascii` glyph sets.
46
48
 
47
49
  ## Commands
48
50
 
@@ -113,7 +115,7 @@ See [docs/footer.md](docs/footer.md) for the full variable and segment reference
113
115
  | [`extensions/hud/ui/`](extensions/hud/ui/) | Icons and terminal style utilities |
114
116
  | [`docs/footer.md`](docs/footer.md) | Complete footer configuration reference |
115
117
  | [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) | Local development, testing guidance, and pull request checklist |
116
- | [`tests/`](tests/) | Vitest suite: footer layout, telemetry, provider balances, MCP parsing |
118
+ | [`tests/`](tests/) | Vitest suite: footer layout, telemetry, provider balances, MCP configuration counts |
117
119
 
118
120
  ## Development
119
121
 
@@ -154,7 +156,7 @@ The footer measures the terminal and wraps at segment boundaries, so narrow wind
154
156
  <details>
155
157
  <summary>Colors look incorrect</summary>
156
158
 
157
- The default palette emits `38;2;r;g;b` Truecolor sequences that terminals without Truecolor support approximate or ignore.
159
+ The default RGB palette follows Pi's detected terminal color mode, with 256-color approximation when needed. Check terminal capability settings and try the theme color source in `/zentui`.
158
160
  </details>
159
161
 
160
162
  <details>
@@ -50,7 +50,8 @@ npm run pack:check
50
50
 
51
51
  Tests live in `tests/`:
52
52
 
53
- - `zentui-status.test.ts` — Config counts, MCP status parsing, and extension status helpers.
53
+ - `zentui-status.test.ts` — Config counts, MCP configuration counts, and extension status helpers.
54
+ - `mcp-footer.test.ts` — MCP count rendering, segment controls, custom formats, and legacy third-party statuses.
54
55
  - `codex-usage.test.ts` — Codex payload normalization and model-specific quota formatting.
55
56
  - `footer-layout.test.ts` — Four-group categorized layout, segment wrapping, and narrow-width budget.
56
57
  - `footer-groups.test.ts` — Semantic Project, Session, Activity, and Usage segment ordering.
@@ -20,6 +20,8 @@ Rinco Pi HUD 用四组实时状态替换 Pi 的脚注行,不需要的部分都
20
20
 
21
21
  ## 安装
22
22
 
23
+ 需要 **Pi 1.3.0 或更新的 1.x 版本**。开发与 CI 依赖固定为 1.3.0。
24
+
23
25
  ```bash
24
26
  pi install npm:rinco-pi-hud
25
27
  ```
@@ -42,7 +44,7 @@ pi install "$PWD"
42
44
  > Pi 扩展以当前用户权限运行。安装任何第三方扩展前,请先审查其源码。
43
45
 
44
46
  > [!NOTE]
45
- > 脚注需要支持 Truecolor 的终端。上图用的是 ASCII 图标模式,`/zentui` 可以在 `auto`、`nerd`、`ascii` 三套符号之间切换。
47
+ > 支持 Truecolor 和 256 色终端;显式 RGB 颜色通过 Pi 的终端能力适配接口输出。上图用的是 ASCII 图标模式,`/zentui` 可以在 `auto`、`nerd`、`ascii` 三套符号之间切换。
46
48
 
47
49
  ## 命令
48
50
 
@@ -113,7 +115,7 @@ Usage 行跟随当前模型的 Provider:
113
115
  | [`extensions/hud/ui/`](../extensions/hud/ui/) | 图标与终端样式工具 |
114
116
  | [`docs/footer.md`](footer.md) | 完整脚注配置参考 |
115
117
  | [`docs/CONTRIBUTING.md`](CONTRIBUTING.md) | 本地开发、测试规范和 PR 检查清单 |
116
- | [`tests/`](../tests/) | Vitest 测试套件:脚注布局、遥测、各家余额与 MCP 解析 |
118
+ | [`tests/`](../tests/) | Vitest 测试套件:脚注布局、遥测、各家余额与 MCP 配置计数 |
117
119
 
118
120
  ## 开发
119
121
 
@@ -154,7 +156,7 @@ npx tsx assets/readme/source/build-assets.mts /tmp/footer.json
154
156
  <details>
155
157
  <summary>颜色显示异常</summary>
156
158
 
157
- 默认配色输出 `38;2;r;g;b` Truecolor 序列,不支持 Truecolor 的终端会近似处理或直接忽略。
159
+ 默认 RGB 配色跟随 Pi 检测到的终端颜色模式,必要时近似为 256 色。请检查终端能力配置,也可以在 `/zentui` 中切换到主题色源。
158
160
  </details>
159
161
 
160
162
  <details>
package/docs/footer.md CHANGED
@@ -7,10 +7,12 @@ When `footerFormat` is empty, the HUD displays four semantic groups:
7
7
  ```text
8
8
  Project · pi-sakura ·  main ·  24.18
9
9
  Session · gpt-5.3 · Thinking level: high · Turn 8
10
- Activity · Agent idle · Skill 2/3 · MCP 2/2
10
+ Activity · Agent idle · Skill 2/3 · MCP 2
11
11
  Usage · 35%/200k · ↑ 4.2k · ↓ 1.1k · $ 0.030
12
12
  ```
13
13
 
14
+ The MCP segment counts server entries in Pi's agent-directory `mcp.json` and the project's `.pi/mcp.json`. Same-name entries count once; disabled entries are included. This is a raw file-entry count: differently spelled names such as `my-server` and `my_server` remain separate, even though Pi may reject them as a namespace collision. Zero entries hide the segment. Counts refresh with the project state polling interval. This does not report connection state, validate individual server settings, or include servers registered dynamically by extensions. Missing, unreadable, malformed, or larger-than-1-MiB files contribute no entries. Legacy adapter footer statuses remain ordinary third-party statuses.
15
+
14
16
  The category column is sized from visible text so every separator aligns. When a row exceeds the terminal width, complete status segments move to an indented continuation line. Only a single segment that is wider than the available content area is truncated. Third-party extension statuses appear on the Activity group in configured `left`, `middle`, then `right` order.
15
17
 
16
18
  Control each default-layout row independently:
@@ -50,7 +52,7 @@ The command shape is `/zentui row <project|session|activity|usage> <enable|disab
50
52
  | `activeAgents` | on | Activity | Active primary agent runs |
51
53
  | `agentIdle` | on | Activity | `Agent idle` when no primary agent run is active |
52
54
  | `skills` | on | Activity | Active/available skills count, e.g. `Skill 1/3` |
53
- | `mcp` | on | Activity | MCP connected/total servers |
55
+ | `mcp` | on | Activity | File-configured MCP servers, e.g. `MCP 2` |
54
56
  | `context` | on | Usage | Context percentage, window size, and/or gauge |
55
57
  | `inputTokens` | on | Usage | Cumulative input tokens |
56
58
  | `outputTokens` | on | Usage | Cumulative output tokens |
@@ -119,7 +121,7 @@ Clear it:
119
121
  | `$skills` | Active/available skills, e.g. `Skill 1/3` |
120
122
  | `$active_skills` | Active skills count only |
121
123
  | `$extensions` | Installed Pi packages count |
122
- | `$mcp` | MCP connected/total |
124
+ | `$mcp` | File-configured MCP server count |
123
125
  | `$tool_counts` | Native tool completion counts |
124
126
  | `$running_tools` | Recently running tools |
125
127
  | `$active_agents` | Active primary agent runs |
@@ -42,6 +42,7 @@ export function registerSettingsController(pi: ExtensionAPI, deps: SettingsContr
42
42
  },
43
43
  setFooterRows(patch) {
44
44
  updateConfig(saveFooterRowsPatch(patch));
45
+ deps.restartSessionTimer();
45
46
  },
46
47
  setFooterSegments(patch) {
47
48
  updateConfig(saveFooterSegmentsPatch(patch));
@@ -133,7 +133,7 @@ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
133
133
  activeAgents: "Active agents",
134
134
  agentIdle: "Agent idle status",
135
135
  skills: "Skill activity",
136
- mcp: "MCP connections",
136
+ mcp: "MCP configured servers",
137
137
  context: "Context usage",
138
138
  inputTokens: "Input tokens",
139
139
  outputTokens: "Output tokens",
@@ -173,7 +173,7 @@ const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> =
173
173
  codexUsage: "Show OpenAI Codex subscription rate limits on the usage row when available.",
174
174
  configCounts: "Show instruction-file and installed Pi package counts on the project row.",
175
175
  skills: "Show active/available Pi skill counts on the activity row.",
176
- mcp: "Show parsed MCP connected/total server counts on the activity row.",
176
+ mcp: "Show the configured server count from global and project mcp.json, including disabled entries.",
177
177
  runningTools: "Show currently running native tools and their elapsed time.",
178
178
  toolCounts: "Show cumulative completed native-tool counts.",
179
179
  activeAgents: "Show active main agent runs on the activity row (not subagent count).",
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { readFileSync, statSync } from "node:fs";
3
3
  import { isAbsolute, join } from "node:path";
4
+ import { countConfiguredMcpServers } from "../segments/mcp-status.js";
4
5
 
5
6
  export interface ConfigCounts {
6
7
  instructionFiles: {
@@ -9,6 +10,7 @@ export interface ConfigCounts {
9
10
  total: number;
10
11
  };
11
12
  packages: number;
13
+ mcpServers: number;
12
14
  }
13
15
 
14
16
  export interface ConfigCountOptions {
@@ -82,6 +84,7 @@ export function countConfigEntries(cwd: string, options: ConfigCountOptions = {}
82
84
  total: agentsMd + claudeMd,
83
85
  },
84
86
  packages: countPackages(agentDir),
87
+ mcpServers: countConfiguredMcpServers(cwd, agentDir),
85
88
  };
86
89
  }
87
90
 
@@ -7,24 +7,48 @@
7
7
  * variable (and nested group) renders empty.
8
8
  */
9
9
 
10
- export type FormatToken =
10
+ export type FormatToken = Readonly<
11
11
  | { kind: "text"; value: string }
12
12
  | { kind: "var"; name: string }
13
13
  | { kind: "fill" }
14
- | { kind: "group"; tokens: FormatToken[] };
14
+ | { kind: "group"; tokens: readonly FormatToken[] }
15
+ >;
16
+
17
+ const FORMAT_CACHE_LIMIT = 128;
18
+ const FORMAT_CACHE_MAX_LENGTH = 16384;
19
+ const formatCache = new Map<string, readonly FormatToken[]>();
20
+
21
+ function freezeTokens(tokens: readonly FormatToken[]): readonly FormatToken[] {
22
+ for (const token of tokens) {
23
+ if (token.kind === "group") freezeTokens(token.tokens);
24
+ Object.freeze(token);
25
+ }
26
+ return Object.freeze(tokens);
27
+ }
15
28
 
16
29
  const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
17
30
 
18
31
  /**
19
- * Tokenize a format string into text/var/fill/group tokens.
32
+ * Tokenize a format string into deeply frozen text/var/fill/group tokens.
33
+ * Repeated bounded inputs reuse the same immutable AST; rendered values are never cached.
20
34
  *
21
35
  * `$name` and `${name}` both produce a variable token. A variable named
22
36
  * `fill` becomes a fill token instead. Parentheses form conditional groups
23
37
  * that drop entirely when all nested vars are empty.
24
38
  */
25
- export function parseFooterFormat(format: string): FormatToken[] {
26
- if (!format) return [];
27
- return parseTokenSlice(format, 0, format.length, true).tokens;
39
+ export function parseFooterFormat(format: string): readonly FormatToken[] {
40
+ const cached = formatCache.get(format);
41
+ if (cached) return cached;
42
+ const tokens = freezeTokens(parseTokenSlice(format, 0, format.length, true).tokens);
43
+ // Bound both entry count and retained input size, including ad-hoc format strings.
44
+ if (format.length <= FORMAT_CACHE_MAX_LENGTH) {
45
+ if (formatCache.size >= FORMAT_CACHE_LIMIT) {
46
+ const oldest = formatCache.keys().next().value;
47
+ if (oldest !== undefined) formatCache.delete(oldest);
48
+ }
49
+ formatCache.set(format, tokens);
50
+ }
51
+ return tokens;
28
52
  }
29
53
 
30
54
  function parseTokenSlice(
@@ -112,7 +136,7 @@ function parseTokenSlice(
112
136
  * spaces are inserted — the user controls all spacing.
113
137
  */
114
138
  export function renderFormatSplit(
115
- tokens: FormatToken[],
139
+ tokens: readonly FormatToken[],
116
140
  renderVariable: (name: string) => string,
117
141
  ): { left: string; middle: string; right: string } {
118
142
  const fillIndices = findTopLevelFillIndices(tokens);
@@ -151,7 +175,7 @@ export function renderFormatSplit(
151
175
  };
152
176
  }
153
177
 
154
- function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
178
+ function findTopLevelFillIndices(tokens: readonly FormatToken[]): number[] {
155
179
  const fillIndices: number[] = [];
156
180
  for (let index = 0; index < tokens.length; index++) {
157
181
  if (tokens[index]?.kind === "fill") fillIndices.push(index);
@@ -160,7 +184,7 @@ function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
160
184
  }
161
185
 
162
186
  function renderTokenSlice(
163
- tokens: FormatToken[],
187
+ tokens: readonly FormatToken[],
164
188
  start: number,
165
189
  end: number,
166
190
  renderVariable: (name: string) => string,
@@ -31,7 +31,6 @@ import {
31
31
  } from "../telemetry/format.js";
32
32
  import { resolveRuntimeSymbol } from "../ui/icons.js";
33
33
  import type { LiveContextOverride } from "../session/live-context.js";
34
- import { parseMcpStatus } from "../segments/mcp-status.js";
35
34
  import type { FooterState } from "../state/index.js";
36
35
  import { renderStyleForSource } from "../ui/style.js";
37
36
 
@@ -74,7 +73,6 @@ export function installFooter(
74
73
  const colorSource = config.colorSources.starship;
75
74
  const iconMode = config.icons.mode;
76
75
  const rawExtensionStatuses = footerData.getExtensionStatuses();
77
- const mcpStatus = parseMcpStatus(rawExtensionStatuses.get("mcp"));
78
76
  const { safeCwd, safeRuntime, safePackageVersion, safeCommit } = sanitizeFooterInputs(
79
77
  ctx,
80
78
  state,
@@ -166,7 +164,7 @@ export function installFooter(
166
164
  runningToolsLabel,
167
165
  activeAgentsLabel,
168
166
  agentIdleLabel,
169
- } = buildTelemetryLabels(ctx, state, config, theme, colorSource, mcpStatus);
167
+ } = buildTelemetryLabels(ctx, state, config, theme, colorSource);
170
168
  const renderVariable = (name: string): string => {
171
169
  const canonical = FOOTER_FORMAT_ALIASES[name] ?? name;
172
170
  switch (canonical) {
@@ -439,14 +437,7 @@ export function installFooter(
439
437
  formatTimeLabel(""),
440
438
  )}`
441
439
  : "";
442
- const formatNeedsMcp = /\$\{?mcp\b/.test(config.footerFormat);
443
- const dedicatedMcpVisible = Boolean(
444
- mcpStatus && (config.footerFormat ? formatNeedsMcp : config.footerSegments.mcp),
445
- );
446
- const extensionStatusSource = dedicatedMcpVisible
447
- ? new Map([...rawExtensionStatuses].filter(([key]) => key !== "mcp"))
448
- : rawExtensionStatuses;
449
- const extensionStatuses = collectExtensionStatusSegments(extensionStatusSource, config);
440
+ const extensionStatuses = collectExtensionStatusSegments(rawExtensionStatuses, config);
450
441
  const renderExtensionStatus = (segment: ExtensionStatusSegment) =>
451
442
  segment.colorMode === "original"
452
443
  ? segment.text
@@ -11,7 +11,6 @@
11
11
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
12
  import { truncateToWidth } from "@earendil-works/pi-tui";
13
13
  import type { ColorSource, PolishedTuiConfig } from "../config/config.js";
14
- import type { McpStatus } from "../segments/mcp-status.js";
15
14
  import { formatSkillCounts } from "../segments/skill-activity.js";
16
15
  import type { FooterState } from "../state/index.js";
17
16
  import { getTelemetryStats } from "../state/telemetry.js";
@@ -164,7 +163,6 @@ export function buildTelemetryLabels(
164
163
  config: PolishedTuiConfig,
165
164
  theme: ThemeLike,
166
165
  colorSource: ColorSource,
167
- mcpStatus: McpStatus | undefined,
168
166
  ): TelemetryLabels {
169
167
  const statusStyle = (style: string, text: string) =>
170
168
  text ? renderStyleForSource(theme, colorSource, style, text) : "";
@@ -267,9 +265,10 @@ export function buildTelemetryLabels(
267
265
  : "";
268
266
  const configCountsLabel = [instructionFilesLabel, extensionsLabel].filter(Boolean).join(" ");
269
267
 
270
- const mcpLabel = mcpStatus
271
- ? statusStyle(config.colors.gitStatus, `MCP ${mcpStatus.connected}/${mcpStatus.total}`)
272
- : "";
268
+ const mcpLabel =
269
+ state.configCounts.mcpServers > 0
270
+ ? statusStyle(config.colors.gitStatus, `MCP ${state.configCounts.mcpServers}`)
271
+ : "";
273
272
 
274
273
  const telemetryStats = getTelemetryStats(state.telemetry);
275
274
  const toolCountsText = Object.entries(telemetryStats.completedToolCounts)
@@ -3,6 +3,7 @@ import registerCodexUsage from "./telemetry/codex-usage/index.js";
3
3
  import { ensureConfigExists, loadConfig, type PolishedTuiConfig } from "./config/config.js";
4
4
  import { installFooter } from "./footer/index.js";
5
5
  import { buildSessionDurationLabel } from "./telemetry/format.js";
6
+ import { footerClockNeeds } from "./session/footer-clock.js";
6
7
  import { emptyGitStatus } from "./segments/git.js";
7
8
  import { LiveContextController } from "./session/live-context.js";
8
9
  import { createProjectStateController } from "./state/project-controller.js";
@@ -33,6 +34,7 @@ export default function (pi: ExtensionAPI) {
33
34
  let footerInstalled = false;
34
35
  let stopSessionTimer: () => void = () => {};
35
36
  let lastDurationLabel = "";
37
+ let sessionTimerKey = "";
36
38
 
37
39
  const refresh = () => {
38
40
  if (sessionLifecycle.isCurrent()) requestFooterRender?.();
@@ -83,24 +85,29 @@ export default function (pi: ExtensionAPI) {
83
85
  const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
84
86
  if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
85
87
  syncFooterState(ctx);
88
+ startSessionTimer();
86
89
  if (project && currentConfig.features.statusLine) scheduleProjectRefresh(ctx);
87
90
  refresh();
88
91
  };
89
92
 
90
93
  const startSessionTimer = () => {
94
+ const needs = footerClockNeeds(
95
+ currentConfig,
96
+ state.telemetry.tools.some((tool) => tool.status === "running"),
97
+ );
98
+ const key = footerInstalled ? JSON.stringify(needs) : "";
99
+ if (sessionTimerKey === key) return;
91
100
  stopSessionTimer();
92
- lastDurationLabel = "";
93
- const segments = currentConfig.footerSegments;
94
- const format = currentConfig.footerFormat ?? "";
95
- const needsWallClock = segments.time || /\$\{?time\b/.test(format);
96
- const needsDuration =
97
- segments.sessionDuration || /\$\{?(?:session_duration|duration)\b/.test(format);
98
- const needsActivityClock = segments.runningTools || /\$\{?running_tools\b/.test(format);
99
- if (
100
- !currentConfig.features.statusLine ||
101
- !(needsWallClock || needsDuration || needsActivityClock)
102
- )
103
- return;
101
+ sessionTimerKey = key;
102
+ lastDurationLabel = state.sessionStartEpoch
103
+ ? buildSessionDurationLabel(state.sessionStartEpoch)
104
+ : "";
105
+ const {
106
+ wallClock: needsWallClock,
107
+ duration: needsDuration,
108
+ activity: needsActivityClock,
109
+ } = needs;
110
+ if (!footerInstalled || !(needsWallClock || needsDuration || needsActivityClock)) return;
104
111
 
105
112
  const timer = setInterval(() => {
106
113
  if (!sessionLifecycle.isCurrent()) return;
@@ -118,6 +125,7 @@ export default function (pi: ExtensionAPI) {
118
125
  timer.unref?.();
119
126
  stopSessionTimer = () => {
120
127
  clearInterval(timer);
128
+ sessionTimerKey = "";
121
129
  stopSessionTimer = () => {};
122
130
  };
123
131
  };
@@ -1,26 +1,31 @@
1
- import { stripVTControlCharacters } from "node:util";
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
2
3
 
3
- export interface McpStatus {
4
- connected: number;
5
- total: number;
4
+ const MAX_CONFIG_BYTES = 1024 * 1024;
5
+
6
+ function isRecord(value: unknown): value is Record<string, unknown> {
7
+ return value !== null && typeof value === "object" && !Array.isArray(value);
6
8
  }
7
9
 
8
- /** Parses the adapter's public status text without intercepting or modifying UI methods. */
9
- export function parseMcpStatus(text: string | undefined): McpStatus | undefined {
10
- if (typeof text !== "string" || text.length === 0) return undefined;
11
- const status = stripVTControlCharacters(text.slice(0, 2048));
12
- const enabledMatch = status.match(
13
- /MCP:\s*(\d{1,5})\s+servers?\s+enabled(?:\s*\(\s*(\d{1,5})\s+connected\s*\))?(?:\s*\(\s*(\d{1,5})\s+disabled\s*\))?\s*$/i,
14
- );
15
- const legacyMatch = status.match(/MCP:\s*(\d{1,5})\s*\/\s*(\d{1,5})\s+servers\b/i);
16
- if (!enabledMatch && !legacyMatch) return undefined;
17
- const connected = Number(enabledMatch ? (enabledMatch[2] ?? 0) : legacyMatch![1]);
18
- const total = Number(enabledMatch ? enabledMatch[1] : legacyMatch![2]);
19
- const disabled = enabledMatch?.[3] === undefined ? 0 : Number(enabledMatch[3]);
20
- if (![connected, total, disabled].every(Number.isSafeInteger)) return undefined;
21
- if (connected < 0 || total < 0 || connected > total || total > 10_000 || disabled > 10_000)
22
- return undefined;
23
- return { connected, total };
10
+ function readServerNames(path: string): string[] {
11
+ try {
12
+ const stats = statSync(path);
13
+ if (!stats.isFile() || stats.size > MAX_CONFIG_BYTES) return [];
14
+ const config: unknown = JSON.parse(readFileSync(path, "utf8"));
15
+ if (!isRecord(config) || !isRecord(config.mcpServers)) return [];
16
+ return Object.entries(config.mcpServers)
17
+ .filter(([name, entry]) => /^[A-Za-z0-9_-]+$/.test(name) && isRecord(entry))
18
+ .map(([name]) => name);
19
+ } catch {
20
+ return [];
21
+ }
24
22
  }
25
23
 
26
- export const parseMcpAdapterStatus = parseMcpStatus;
24
+ /** Count file-configured server entries, including disabled ones, without resolving secrets
25
+ * or starting connections. Project entries override global entries with the same name.
26
+ * This is not a connection count or a validation of individual server settings. */
27
+ export function countConfiguredMcpServers(cwd: string, agentDir: string): number {
28
+ const globalNames = agentDir ? readServerNames(join(agentDir, "mcp.json")) : [];
29
+ const projectNames = readServerNames(join(cwd, ".pi", "mcp.json"));
30
+ return new Set([...globalNames, ...projectNames]).size;
31
+ }
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { existsSync, readdirSync, statSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { runtimeDefinitions, type RuntimeDef } from "./runtime-definitions.js";
@@ -15,32 +16,32 @@ export type RuntimeInfo = Pick<RuntimeMetadata, "name" | "symbol" | "style"> & {
15
16
  export type RuntimeReadResult = { kind: "ok"; runtime?: RuntimeInfo } | { kind: "error" };
16
17
 
17
18
  type RuntimeCacheEntry = {
18
- fingerprint: string;
19
- runtime: RuntimeInfo | undefined;
19
+ result: RuntimeReadResult;
20
+ expiresAt: number;
20
21
  };
21
22
 
22
23
  const RUNTIME_CACHE_MAX = 32;
24
+ const RUNTIME_CACHE_TTL_MS = 60_000;
25
+ const RUNTIME_RETRY_TTL_MS = 5_000;
23
26
  const runtimeInfoCache = new Map<string, RuntimeCacheEntry>();
27
+ const runtimeReadsInFlight = new Map<string, Promise<RuntimeReadResult>>();
28
+ let cacheEpoch = 0;
24
29
 
25
30
  export function clearRuntimeInfoCache(): void {
31
+ cacheEpoch += 1;
26
32
  runtimeInfoCache.clear();
33
+ // New callers must not join a probe started before the clear.
34
+ runtimeReadsInFlight.clear();
27
35
  }
28
36
 
29
37
  function topLevelFingerprint(entries: readonly string[]): string {
30
38
  return entries.slice().sort().join("\0");
31
39
  }
32
40
 
33
- function cacheRuntimeInfo(
34
- cwd: string,
35
- fingerprint: string,
36
- runtime: RuntimeInfo | undefined,
37
- ): void {
38
- // Drop prior fingerprints for the same cwd so stale marker sets do not linger.
39
- for (const key of runtimeInfoCache.keys()) {
40
- if (key === cwd || key.startsWith(`${cwd}\0`)) runtimeInfoCache.delete(key);
41
- }
42
- const key = `${cwd}\0${fingerprint}`;
43
- runtimeInfoCache.set(key, { fingerprint, runtime });
41
+ function cacheRuntimeInfo(key: string, result: RuntimeReadResult): void {
42
+ const ttl =
43
+ result.kind === "ok" && result.runtime?.version ? RUNTIME_CACHE_TTL_MS : RUNTIME_RETRY_TTL_MS;
44
+ runtimeInfoCache.set(key, { result, expiresAt: Date.now() + ttl });
44
45
  while (runtimeInfoCache.size > RUNTIME_CACHE_MAX) {
45
46
  const oldest = runtimeInfoCache.keys().next().value;
46
47
  if (oldest === undefined) break;
@@ -140,28 +141,53 @@ export async function readRuntimeInfo(cwd: string): Promise<RuntimeReadResult> {
140
141
  return { kind: "error" };
141
142
  }
142
143
 
143
- const fingerprint = topLevelFingerprint(entries);
144
- const cacheKey = `${cwd}\0${fingerprint}`;
144
+ // Detection uses shell variables and version commands inherit the environment.
145
+ // Include all variables (including PATH and toolchain-manager settings) so a
146
+ // changed environment never reuses the old probe. JSON avoids key collisions.
147
+ const env = { ...process.env };
148
+ const environment = Object.keys(env)
149
+ .sort()
150
+ .map((key) => [key, env[key]]);
151
+ // Hash environment values so cache keys do not retain plaintext credentials.
152
+ const environmentKey = createHash("sha256").update(JSON.stringify(environment)).digest("hex");
153
+ const cacheKey = JSON.stringify([cwd, topLevelFingerprint(entries), environmentKey]);
145
154
  const cached = runtimeInfoCache.get(cacheKey);
146
- if (cached && cached.fingerprint === fingerprint) {
147
- return { kind: "ok", runtime: cached.runtime };
155
+ if (cached) {
156
+ if (Date.now() < cached.expiresAt) return cached.result;
157
+ runtimeInfoCache.delete(cacheKey);
148
158
  }
159
+ const inFlight = runtimeReadsInFlight.get(cacheKey);
160
+ if (inFlight) return inFlight;
149
161
 
150
- try {
151
- const runtime = detectRuntime(cwd, entries);
152
- if (!runtime) {
153
- cacheRuntimeInfo(cwd, fingerprint, undefined);
154
- return { kind: "ok", runtime: undefined };
162
+ const epoch = cacheEpoch;
163
+ const read = async (): Promise<RuntimeReadResult> => {
164
+ try {
165
+ const runtime = detectRuntime(cwd, entries, env);
166
+ if (!runtime) return { kind: "ok", runtime: undefined };
167
+ return {
168
+ kind: "ok",
169
+ runtime: {
170
+ name: runtime.name,
171
+ symbol: runtime.symbol,
172
+ style: runtime.style,
173
+ version: await runtime.version(cwd),
174
+ },
175
+ };
176
+ } catch {
177
+ return { kind: "error" };
155
178
  }
156
- const info: RuntimeInfo = {
157
- name: runtime.name,
158
- symbol: runtime.symbol,
159
- style: runtime.style,
160
- version: await runtime.version(cwd),
161
- };
162
- cacheRuntimeInfo(cwd, fingerprint, info);
163
- return { kind: "ok", runtime: info };
164
- } catch {
165
- return { kind: "error" };
166
- }
179
+ };
180
+ const pending = read()
181
+ .then((result) => {
182
+ // Clearing the cache must also invalidate outstanding writers.
183
+ if (epoch === cacheEpoch) cacheRuntimeInfo(cacheKey, result);
184
+ return result;
185
+ })
186
+ .finally(() => {
187
+ if (runtimeReadsInFlight.get(cacheKey) === pending) {
188
+ runtimeReadsInFlight.delete(cacheKey);
189
+ }
190
+ });
191
+ runtimeReadsInFlight.set(cacheKey, pending);
192
+ return pending;
167
193
  }
@@ -119,13 +119,18 @@ export class SkillActivityTracker {
119
119
  }
120
120
  continue;
121
121
  }
122
- if (
123
- message.role !== "toolResult" ||
124
- message.toolName !== "read" ||
125
- message.isError === true
126
- ) {
127
- continue;
122
+ if (message.role !== "toolResult") continue;
123
+ // Nested calls are persisted on the outer result, even when that outer
124
+ // tool subsequently fails. Restore each successful read independently.
125
+ const nestedCalls = isRecord(message.nestedCalls) ? message.nestedCalls.calls : undefined;
126
+ if (Array.isArray(nestedCalls)) {
127
+ for (const call of nestedCalls) {
128
+ if (!isRecord(call) || call.name !== "read" || call.status !== "ok") continue;
129
+ const path = isRecord(call.args) ? call.args.path : undefined;
130
+ if (typeof path === "string") this.activateFromRead(path, cwd);
131
+ }
128
132
  }
133
+ if (message.toolName !== "read" || message.isError === true) continue;
129
134
  const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
130
135
  const path = id ? pendingReads.get(id) : undefined;
131
136
  if (path) this.activateFromRead(path, cwd);
@@ -64,7 +64,9 @@ export function registerSessionEventHandlers(
64
64
 
65
65
  pi.on("before_agent_start", (event, ctx) => {
66
66
  deps.updateSkillCounts(ctx, event.systemPromptOptions.skills ?? []);
67
- deps.skillActivity.activateFromPrompt(event.prompt, ctx.cwd);
67
+ if (event.messageType === "user") {
68
+ deps.skillActivity.activateFromPrompt(event.prompt, ctx.cwd);
69
+ }
68
70
  deps.state.skillCounts = deps.skillActivity.counts();
69
71
  deps.refreshInteractiveState(ctx);
70
72
  });
@@ -0,0 +1,36 @@
1
+ import type { PolishedTuiConfig } from "../config/config.js";
2
+ import { parseFooterFormat, type FormatToken } from "../footer/format.js";
3
+
4
+ export interface FooterClockNeeds {
5
+ wallClock: boolean;
6
+ duration: boolean;
7
+ activity: boolean;
8
+ }
9
+
10
+ /** Only clocks actually referenced by the visible layout need periodic work. */
11
+ export function footerClockNeeds(
12
+ config: PolishedTuiConfig,
13
+ hasRunningTools: boolean,
14
+ ): FooterClockNeeds {
15
+ if (!config.features.statusLine) return { wallClock: false, duration: false, activity: false };
16
+ if (config.footerFormat) {
17
+ const names = new Set<string>();
18
+ const visit = (tokens: readonly FormatToken[]) => {
19
+ for (const token of tokens) {
20
+ if (token.kind === "var") names.add(token.name);
21
+ if (token.kind === "group") visit(token.tokens);
22
+ }
23
+ };
24
+ visit(parseFooterFormat(config.footerFormat));
25
+ return {
26
+ wallClock: names.has("time"),
27
+ duration: names.has("session_duration") || names.has("duration"),
28
+ activity: hasRunningTools && names.has("running_tools"),
29
+ };
30
+ }
31
+ return {
32
+ wallClock: config.footerRows.usage && config.footerSegments.time,
33
+ duration: config.footerRows.session && config.footerSegments.sessionDuration,
34
+ activity: hasRunningTools && config.footerRows.activity && config.footerSegments.runningTools,
35
+ };
36
+ }
@@ -42,6 +42,7 @@ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
42
42
  configCounts: {
43
43
  instructionFiles: { agentsMd: 0, claudeMd: 0, total: 0 },
44
44
  packages: 0,
45
+ mcpServers: 0,
45
46
  },
46
47
  skillCounts: undefined,
47
48
  codexUsageStatus: undefined,
@@ -17,6 +17,7 @@ import {
17
17
  interface ProjectRefreshTarget {
18
18
  cwd: string;
19
19
  generation: number;
20
+ epoch: number;
20
21
  }
21
22
 
22
23
  export interface ProjectStateController {
@@ -42,9 +43,14 @@ export function createProjectStateController({
42
43
  }: ProjectStateControllerDeps): ProjectStateController {
43
44
  let stopInterval: StopProjectRefreshInterval = () => {};
44
45
  let lastProjectCwd: string | undefined;
46
+ let epoch = 0;
45
47
 
46
- const refreshProjectState = async ({ cwd, generation }: ProjectRefreshTarget): Promise<void> => {
47
- if (!lifecycle.isCurrent(generation)) return;
48
+ const refreshProjectState = async ({
49
+ cwd,
50
+ generation,
51
+ epoch: targetEpoch,
52
+ }: ProjectRefreshTarget): Promise<void> => {
53
+ if (targetEpoch !== epoch || !lifecycle.isCurrent(generation)) return;
48
54
  const config = getConfig();
49
55
  const format = config.footerFormat;
50
56
  const formatNeedsTag = /\$\{?(?:git_tag|tag)\b/.test(format);
@@ -66,7 +72,7 @@ export function createProjectStateController({
66
72
  wantPackage ? readPackageVersionResult(cwd) : Promise.resolve(undefined),
67
73
  Promise.resolve(countConfigEntries(cwd)),
68
74
  ]);
69
- if (!lifecycle.isCurrent(generation)) return;
75
+ if (targetEpoch !== epoch || !lifecycle.isCurrent(generation)) return;
70
76
  state.configCounts = configCounts;
71
77
  lastProjectCwd = applyProjectRefreshToState(state, {
72
78
  cwd,
@@ -79,6 +85,8 @@ export function createProjectStateController({
79
85
 
80
86
  const scheduler = createProjectRefreshScheduler(refreshProjectState, requestRender);
81
87
  const stop = () => {
88
+ // Invalidate work even when the session lifecycle itself has not changed.
89
+ epoch += 1;
82
90
  stopInterval();
83
91
  stopInterval = () => {};
84
92
  scheduler.stop();
@@ -86,11 +94,13 @@ export function createProjectStateController({
86
94
  const schedule = (ctx: ExtensionContext, options?: ScheduleProjectRefreshOptions) => {
87
95
  const generation = lifecycle.currentGeneration();
88
96
  if (!lifecycle.isCurrent(generation)) return;
89
- scheduler.schedule({ cwd: ctx.cwd, generation }, options);
97
+ scheduler.schedule({ cwd: ctx.cwd, generation, epoch }, options);
90
98
  };
91
99
 
92
100
  return {
93
101
  reset() {
102
+ epoch += 1;
103
+ scheduler.stop();
94
104
  lastProjectCwd = undefined;
95
105
  },
96
106
  schedule,
@@ -195,10 +195,13 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
195
195
  return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
196
196
  }
197
197
 
198
- function allowedAuthHeaders(source: Record<string, string>): Record<string, string> {
198
+ function allowedAuthHeaders(source: Record<string, string | null>): Record<string, string> {
199
199
  const allowed = new Set(["authorization", "chatgpt-account-id", "user-agent", "accept"]);
200
200
  return Object.fromEntries(
201
- Object.entries(source).filter(([key]) => allowed.has(key.toLowerCase())),
201
+ Object.entries(source).filter(
202
+ (entry): entry is [string, string] =>
203
+ allowed.has(entry[0].toLowerCase()) && typeof entry[1] === "string",
204
+ ),
202
205
  );
203
206
  }
204
207
 
@@ -85,6 +85,7 @@ type SessionEntry = {
85
85
  type?: string;
86
86
  id?: string | number;
87
87
  timestamp?: string | number;
88
+ usage?: AssistantMessage["usage"];
88
89
  message?: {
89
90
  role?: string;
90
91
  usage?: AssistantMessage["usage"];
@@ -133,9 +134,22 @@ function calculateCacheHitRate(
133
134
  return promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
134
135
  }
135
136
 
137
+ function entryUsage(entry: SessionEntry): AssistantMessage["usage"] | undefined {
138
+ if (entry.type === "usage" || entry.type === "compaction" || entry.type === "branch_summary") {
139
+ return entry.usage;
140
+ }
141
+ if (
142
+ entry.type === "message" &&
143
+ (entry.message?.role === "assistant" || entry.message?.role === "toolResult")
144
+ ) {
145
+ return entry.message.usage;
146
+ }
147
+ return undefined;
148
+ }
149
+
136
150
  function entryIdentity(entry: SessionEntry | undefined): string {
137
151
  if (!entry) return "";
138
- const usage = entry.message?.usage;
152
+ const usage = entryUsage(entry);
139
153
  const usageKey = usage
140
154
  ? `${usage.input ?? 0}:${usage.output ?? 0}:${usage.cacheRead ?? 0}:${usage.cacheWrite ?? 0}:${usage.cost?.total ?? 0}`
141
155
  : "";
@@ -158,8 +172,8 @@ function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
158
172
  let cost = 0;
159
173
 
160
174
  for (const entry of entries) {
161
- if (entry.type !== "message" || entry.message?.role !== "assistant") continue;
162
- const usage = entry.message.usage;
175
+ const usage = entryUsage(entry);
176
+ if (!usage) continue;
163
177
  const entryInput = usage?.input ?? 0;
164
178
  const entryCacheRead = usage?.cacheRead ?? 0;
165
179
  const entryCacheWrite = usage?.cacheWrite ?? 0;
@@ -169,7 +183,9 @@ function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
169
183
  cacheRead += entryCacheRead;
170
184
  cacheWrite += entryCacheWrite;
171
185
  cost += usage?.cost?.total ?? 0;
172
- latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
186
+ if (entry.type === "message" && entry.message?.role === "assistant") {
187
+ latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
188
+ }
173
189
  }
174
190
 
175
191
  return Object.freeze({ input, output, cacheRead, cacheWrite, latestCacheHitRate, cost });
@@ -1,8 +1,10 @@
1
- import type { ThemeColor } from "@earendil-works/pi-coding-agent";
1
+ import type { ThemeColor, ThemeStyle } from "@earendil-works/pi-coding-agent";
2
+ import type { Color } from "@earendil-works/pi-tui";
2
3
  import type { ColorSource, ColorSpec } from "../config/config.js";
3
4
 
4
5
  type ThemeLike = {
5
6
  fg(color: string, text: string): string;
7
+ style?: (text: string, options: ThemeStyle) => string;
6
8
  bold?: (text: string) => string;
7
9
  italic?: (text: string) => string;
8
10
  underline?: (text: string) => string;
@@ -126,6 +128,10 @@ const themeColorTokens = new Set<ThemeColor>([
126
128
  "thinkingMedium",
127
129
  "thinkingHigh",
128
130
  "thinkingXhigh",
131
+ "thinkingMax",
132
+ "scrollbarTrack",
133
+ "scrollbarThumb",
134
+ "searchMatchText",
129
135
  "bashMode",
130
136
  ]);
131
137
 
@@ -164,12 +170,14 @@ function isSupportedStyleToken(token: string): boolean {
164
170
  }
165
171
 
166
172
  export function isSupportedColorSpec(style: ColorSpec): boolean {
167
- const trimmed = style.trim();
168
- if (trimmed === "") return true;
169
- return trimmed.split(/\s+/).every(isSupportedStyleToken);
173
+ return parseStyle(style).supported;
170
174
  }
171
175
 
172
- function applyThemeModifiers(theme: ThemeLike, styleTokens: string[], text: string): string {
176
+ function applyThemeModifiers(
177
+ theme: ThemeLike,
178
+ styleTokens: readonly string[],
179
+ text: string,
180
+ ): string {
173
181
  let rendered = text;
174
182
  for (const token of styleTokens) {
175
183
  const normalized = token.toLowerCase();
@@ -188,7 +196,7 @@ export function safeThemeFg(theme: ThemeLike, color: string, text: string): stri
188
196
  }
189
197
  }
190
198
 
191
- function mapThemeColor(styleTokens: string[]): string | undefined {
199
+ function mapThemeColor(styleTokens: readonly string[]): string | undefined {
192
200
  let fallback: string | undefined;
193
201
  for (const token of styleTokens) {
194
202
  const normalized = token.toLowerCase();
@@ -212,35 +220,114 @@ function mapThemeColor(styleTokens: string[]): string | undefined {
212
220
  */
213
221
  export function colorize(theme: ThemeLike, color: ColorSpec, text: string): string {
214
222
  if (isHexColor(color)) {
223
+ try {
224
+ if (theme.style) return theme.style(text, { fg: hexColor(color) });
225
+ } catch {
226
+ // Older/mock themes retain the original truecolor fallback.
227
+ }
215
228
  return `${hexToAnsi(color)}${text}\x1b[39m`;
216
229
  }
217
230
  return safeThemeFg(theme, color, text);
218
231
  }
219
232
 
220
- /**
221
- * Render text with Starship-style terminal styling strings (e.g. "bold red", "fg:202",
222
- * "bg:blue", "underline bg:#bf5700").
223
- */
224
- export function renderTerminalStyle(style: string, text: string): string {
225
- const codes: string[] = [];
226
- for (const token of style.trim().split(/\s+/)) {
227
- if (!token) continue;
233
+ type ParsedStyle = {
234
+ readonly tokens: readonly string[];
235
+ readonly codes: string;
236
+ readonly options: Readonly<ThemeStyle>;
237
+ readonly hasHex: boolean;
238
+ readonly explicit: boolean;
239
+ readonly supported: boolean;
240
+ };
241
+
242
+ // Cache syntax only: never retain a Theme or themed ANSI across theme changes.
243
+ const STYLE_CACHE_LIMIT = 256;
244
+ const STYLE_CACHE_MAX_LENGTH = 4096;
245
+ const styleCache = new Map<string, ParsedStyle>();
246
+
247
+ function hexColor(hex: string): Color {
248
+ const normalized = expandHexColor(hex);
249
+ return Object.freeze({
250
+ kind: "rgb",
251
+ r: Number.parseInt(normalized.slice(0, 2), 16),
252
+ g: Number.parseInt(normalized.slice(2, 4), 16),
253
+ b: Number.parseInt(normalized.slice(4, 6), 16),
254
+ });
255
+ }
228
256
 
257
+ function parseStyle(style: string): ParsedStyle {
258
+ const cached = styleCache.get(style);
259
+ if (cached) return cached;
260
+ const tokens = Object.freeze(style.trim().split(/\s+/).filter(Boolean));
261
+ const codes: string[] = [];
262
+ const options: ThemeStyle = {};
263
+ let hasHex = false;
264
+ for (const token of tokens) {
229
265
  const normalized = token.toLowerCase();
230
266
  const modifier = terminalStyleModifiers.get(normalized);
231
267
  if (modifier !== undefined) {
232
268
  codes.push(`${modifier}`);
269
+ const attribute = normalized === "dimmed" ? "dim" : normalized;
270
+ if (
271
+ attribute === "bold" ||
272
+ attribute === "dim" ||
273
+ attribute === "italic" ||
274
+ attribute === "underline"
275
+ )
276
+ options[attribute] = true;
233
277
  continue;
234
278
  }
235
-
236
279
  const isForeground = normalized.startsWith("fg:");
237
280
  const isBackground = normalized.startsWith("bg:");
238
281
  const colorName = isForeground || isBackground ? normalized.slice(3) : normalized;
239
- const color = terminalColorToAnsi(colorName, isBackground);
240
- if (color) codes.push(color);
282
+ const code = terminalColorToAnsi(colorName, isBackground);
283
+ if (!code) continue;
284
+ codes.push(code);
285
+ let color: Color;
286
+ if (isHexColor(colorName)) {
287
+ hasHex = true;
288
+ color = hexColor(colorName);
289
+ } else {
290
+ const named = terminalColorCodes.get(colorName);
291
+ color = Object.freeze({
292
+ kind: "indexed",
293
+ index: named === undefined ? Number(colorName) : named >= 90 ? named - 90 + 8 : named - 30,
294
+ });
295
+ }
296
+ options[isBackground ? "bg" : "fg"] = color;
241
297
  }
298
+ const parsed = Object.freeze({
299
+ tokens,
300
+ codes: codes.join(";"),
301
+ options: Object.freeze(options),
302
+ hasHex,
303
+ explicit: tokens.some(isExplicitTerminalColorToken),
304
+ supported: tokens.every(isSupportedStyleToken),
305
+ });
306
+ if (style.length <= STYLE_CACHE_MAX_LENGTH) {
307
+ if (styleCache.size >= STYLE_CACHE_LIMIT) {
308
+ const oldest = styleCache.keys().next().value;
309
+ if (oldest !== undefined) styleCache.delete(oldest);
310
+ }
311
+ styleCache.set(style, parsed);
312
+ }
313
+ return parsed;
314
+ }
242
315
 
243
- return codes.length ? `\x1b[${codes.join(";")}m${text}\x1b[0m` : text;
316
+ /**
317
+ * Render Starship terminal styles. With a Theme, explicit RGB uses its color-mode encoder;
318
+ * named/indexed-only styles and simple mocks retain the original terminal ANSI semantics.
319
+ */
320
+ export function renderTerminalStyle(style: string, text: string, theme?: ThemeLike): string {
321
+ const parsed = parseStyle(style);
322
+ if (parsed.hasHex && theme?.style) {
323
+ try {
324
+ // Pass a fresh options object so a custom theme cannot mutate cached syntax.
325
+ return theme.style(text, { ...parsed.options });
326
+ } catch {
327
+ // Preserve terminal styling for partial/legacy theme implementations.
328
+ }
329
+ }
330
+ return parsed.codes ? `\x1b[${parsed.codes}m${text}\x1b[0m` : text;
244
331
  }
245
332
 
246
333
  /**
@@ -248,17 +335,16 @@ export function renderTerminalStyle(style: string, text: string): string {
248
335
  * legacy config values such as "accent" or "syntaxKeyword".
249
336
  */
250
337
  export function renderStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
251
- if (style.trim() === "") return text;
252
- const styled = renderTerminalStyle(style, text);
338
+ if (parseStyle(style).tokens.length === 0) return text;
339
+ const styled = renderTerminalStyle(style, text, theme);
253
340
  return styled === text ? colorize(theme, style, text) : styled;
254
341
  }
255
342
 
256
343
  export function renderThemeStyle(theme: ThemeLike, style: ColorSpec, text: string): string {
257
- const trimmed = style.trim();
258
- if (trimmed === "") return text;
259
-
260
- const tokens = trimmed.split(/\s+/).filter(Boolean);
261
- if (tokens.some(isExplicitTerminalColorToken)) return renderTerminalStyle(style, text);
344
+ const parsed = parseStyle(style);
345
+ const tokens = parsed.tokens;
346
+ if (tokens.length === 0) return text;
347
+ if (parsed.explicit) return renderTerminalStyle(style, text, theme);
262
348
 
263
349
  const color = mapThemeColor(tokens) ?? "text";
264
350
  return safeThemeFg(theme, color, applyThemeModifiers(theme, tokens, text));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rinco-pi-hud",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "A dynamic HUD footer for Pi, ported from Sakura Zentui Footer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,8 +39,9 @@
39
39
  "pack:check": "npm pack --dry-run"
40
40
  },
41
41
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": "*",
43
- "@earendil-works/pi-tui": "*"
42
+ "@earendil-works/pi-ai": "^1.3.0",
43
+ "@earendil-works/pi-coding-agent": "^1.3.0",
44
+ "@earendil-works/pi-tui": "^1.3.0"
44
45
  },
45
46
  "pi": {
46
47
  "extensions": [
@@ -48,7 +49,9 @@
48
49
  ]
49
50
  },
50
51
  "devDependencies": {
51
- "@earendil-works/pi-ai": "^0.83.0",
52
+ "@earendil-works/pi-ai": "1.3.0",
53
+ "@earendil-works/pi-coding-agent": "1.3.0",
54
+ "@earendil-works/pi-tui": "1.3.0",
52
55
  "@eslint/js": "^10.0.1",
53
56
  "@types/node": "^24.12.4",
54
57
  "eslint": "^10.8.1",