rinco-pi-hud 1.3.1 → 1.5.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 +6 -2
- package/docs/README.zh-CN.md +6 -2
- package/docs/footer.md +19 -1
- package/extensions/hud/commands/settings-controller.ts +1 -0
- package/extensions/hud/commands/settings.ts +1 -1
- package/extensions/hud/config/normalize.ts +2 -1
- package/extensions/hud/config/schema.ts +45 -21
- package/extensions/hud/footer/format.ts +33 -9
- package/extensions/hud/footer/index.ts +3 -7
- package/extensions/hud/footer/render-labels.ts +2 -2
- package/extensions/hud/index.ts +20 -12
- package/extensions/hud/segments/runtime-definitions.ts +12 -0
- package/extensions/hud/segments/runtime.ts +65 -35
- package/extensions/hud/segments/skill-activity.ts +11 -6
- package/extensions/hud/session/event-handlers.ts +3 -1
- package/extensions/hud/session/footer-clock.ts +36 -0
- package/extensions/hud/state/project-controller.ts +14 -4
- package/extensions/hud/telemetry/codex-usage/query.ts +5 -2
- package/extensions/hud/telemetry/format.ts +35 -7
- package/extensions/hud/ui/style.ts +111 -25
- package/package.json +7 -4
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
|
-
>
|
|
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
|
|
|
@@ -96,6 +98,8 @@ Variables accept `$name` or `${name}`. The [footer reference](docs/footer.md) li
|
|
|
96
98
|
|
|
97
99
|
`/zentui` writes to `~/.pi/agent/rinco-pi-hud.json` and applies changes immediately. It covers the separator style, icon mode (`nerd`, `ascii`, or `auto`), footer colors (Pi theme tokens or terminal palette styles), context rendering (text, gauge, or both), the project refresh interval, and third-party extension statuses with their own placement and color mode.
|
|
98
100
|
|
|
101
|
+
Footer colors follow the active Pi theme by default, including theme switches and automatic light/dark changes. In `/zentui`, **Footer colors → theme** enables this behavior; **terminal** keeps the original Sakura RGB palette. Explicit custom colors (hex, indexed, or `fg:`/`bg:` styles) remain fixed in either mode. Remove those entries from `colors` to restore automatic theme colors.
|
|
102
|
+
|
|
99
103
|
See [docs/footer.md](docs/footer.md) for the full variable and segment reference.
|
|
100
104
|
|
|
101
105
|
## Project contents
|
|
@@ -154,7 +158,7 @@ The footer measures the terminal and wraps at segment boundaries, so narrow wind
|
|
|
154
158
|
<details>
|
|
155
159
|
<summary>Colors look incorrect</summary>
|
|
156
160
|
|
|
157
|
-
The default palette
|
|
161
|
+
The default palette follows the active Pi theme. If the footer still uses fixed colors, select **Footer colors → theme** in `/zentui` and remove explicit color overrides from `~/.pi/agent/rinco-pi-hud.json`. Third-party statuses set to `original` retain their own colors. Truecolor and 256-color output follow Pi's detected terminal capabilities.
|
|
158
162
|
</details>
|
|
159
163
|
|
|
160
164
|
<details>
|
package/docs/README.zh-CN.md
CHANGED
|
@@ -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
|
-
>
|
|
47
|
+
> 支持 Truecolor 和 256 色终端;显式 RGB 颜色通过 Pi 的终端能力适配接口输出。上图用的是 ASCII 图标模式,`/zentui` 可以在 `auto`、`nerd`、`ascii` 三套符号之间切换。
|
|
46
48
|
|
|
47
49
|
## 命令
|
|
48
50
|
|
|
@@ -96,6 +98,8 @@ Usage 行跟随当前模型的 Provider:
|
|
|
96
98
|
|
|
97
99
|
`/zentui` 把配置写入 `~/.pi/agent/rinco-pi-hud.json`,改动立即生效。可配置分隔符样式、图标模式(`nerd`、`ascii`、`auto`)、脚注配色(Pi 主题色或终端调色板样式)、Context 渲染方式(文本、进度条或两者)、项目刷新间隔,以及第三方扩展状态的位置与颜色模式。
|
|
98
100
|
|
|
101
|
+
脚注默认自动跟随当前 Pi 主题,包括手动切换主题和自动明暗切换。在 `/zentui` 中选择 **Footer colors → theme** 启用跟随,选择 **terminal** 则保留原有 Sakura RGB 配色。显式自定义颜色(十六进制、索引色或 `fg:`/`bg:` 样式)在两种模式下都会保留;删除 `colors` 中的相应配置即可恢复自动配色。
|
|
102
|
+
|
|
99
103
|
完整的变量与状态段参考见 [docs/footer.md](footer.md)。
|
|
100
104
|
|
|
101
105
|
## 仓库结构
|
|
@@ -154,7 +158,7 @@ npx tsx assets/readme/source/build-assets.mts /tmp/footer.json
|
|
|
154
158
|
<details>
|
|
155
159
|
<summary>颜色显示异常</summary>
|
|
156
160
|
|
|
157
|
-
|
|
161
|
+
默认配色自动跟随当前 Pi 主题。如果脚注仍使用固定颜色,请在 `/zentui` 中选择 **Footer colors → theme**,并删除 `~/.pi/agent/rinco-pi-hud.json` 中的显式颜色覆盖。设为 `original` 的第三方扩展状态会保留自己的颜色。Truecolor 与 256 色输出仍按 Pi 检测到的终端能力选择。
|
|
158
162
|
</details>
|
|
159
163
|
|
|
160
164
|
<details>
|
package/docs/footer.md
CHANGED
|
@@ -11,7 +11,7 @@ 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. 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.
|
|
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
15
|
|
|
16
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.
|
|
17
17
|
|
|
@@ -26,6 +26,24 @@ Control each default-layout row independently:
|
|
|
26
26
|
|
|
27
27
|
The command shape is `/zentui row <project|session|activity|usage> <enable|disable|toggle>`. Values persist under `footerRows`; all four default to `true`. Row switches apply only to the default layout and do not alter a custom single-line `footerFormat`.
|
|
28
28
|
|
|
29
|
+
## Colors and theme switching
|
|
30
|
+
|
|
31
|
+
By default, `colorSources.starship` is `"theme"`. Built-in footer colors use semantic Pi theme tokens such as `accent`, `muted`, `success`, `warning`, and `error`; runtime labels also use the active theme. Switching themes, including automatic light/dark switching, recolors the footer without reloading the extension. This applies to both the default rows and custom templates.
|
|
32
|
+
|
|
33
|
+
To enable theme colors in an existing configuration, use **Footer colors → theme** in `/zentui`, or set:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"colorSources": {
|
|
38
|
+
"starship": "theme"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Select `"terminal"` to retain the original Sakura RGB defaults and Starship runtime colors. Changing the source does not write a palette into your configuration.
|
|
44
|
+
|
|
45
|
+
Entries you explicitly set under `colors` override the selected defaults. Semantic tokens (for example, `"cwd": "bold accent"`) follow the theme; hex colors, indexed colors, and explicit `fg:`/`bg:` styles remain fixed. Remove an override to restore the default for that segment. Third-party extension statuses in `original` color mode retain their own ANSI colors; use `zentui` color mode to recolor them with the HUD.
|
|
46
|
+
|
|
29
47
|
## Built-in status segments
|
|
30
48
|
|
|
31
49
|
| Key | Default | Group | Description |
|
|
@@ -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));
|
|
@@ -92,7 +92,7 @@ const colorSettingLabels: Record<ColorSettingId, string> = {
|
|
|
92
92
|
|
|
93
93
|
const colorSettingDescriptions: Record<ColorSettingId, string> = {
|
|
94
94
|
starship:
|
|
95
|
-
"
|
|
95
|
+
"Follow the active Pi theme automatically (theme), or keep the original Sakura/runtime palette (terminal). Explicit color overrides are preserved.",
|
|
96
96
|
};
|
|
97
97
|
|
|
98
98
|
const featureSettingLabels: Record<FeatureSettingId, string> = {
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
type PathDisplayConfig,
|
|
35
35
|
type PolishedTuiConfig,
|
|
36
36
|
type SeparatorStyle,
|
|
37
|
+
terminalDefaultColors,
|
|
37
38
|
type UiFeaturesConfig,
|
|
38
39
|
} from "./schema.js";
|
|
39
40
|
|
|
@@ -374,7 +375,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
374
375
|
gitBranch,
|
|
375
376
|
icons: resolveConfiguredIcons(iconMode, iconOverrides),
|
|
376
377
|
colors: {
|
|
377
|
-
...defaultConfig.colors,
|
|
378
|
+
...(colorSources.starship === "terminal" ? terminalDefaultColors : defaultConfig.colors),
|
|
378
379
|
...colors,
|
|
379
380
|
},
|
|
380
381
|
colorSources: { ...colorSources },
|
|
@@ -226,6 +226,30 @@ export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
|
|
|
226
226
|
|
|
227
227
|
export const configPath = join(getAgentDir(), "rinco-pi-hud.json");
|
|
228
228
|
|
|
229
|
+
/** Preserve the original Sakura palette for users who explicitly select terminal colors. */
|
|
230
|
+
export const terminalDefaultColors: PolishedTuiConfig["colors"] = {
|
|
231
|
+
cwd: "bold #F2A7C6",
|
|
232
|
+
gitBranch: "bold #C7B8F5",
|
|
233
|
+
gitStatus: "bold #F6BC9A",
|
|
234
|
+
contextNormal: "#AEE5C5",
|
|
235
|
+
contextWarning: "bold #F3D98B",
|
|
236
|
+
contextError: "bold #FF8FA3",
|
|
237
|
+
tokens: "#A99BAE",
|
|
238
|
+
cost: "bold #AEE5C5",
|
|
239
|
+
separator: "#716879",
|
|
240
|
+
runtimePrefix: "#9FD3F2",
|
|
241
|
+
extensionStatus: "#EFC3E6",
|
|
242
|
+
sessionDuration: "#F3D98B",
|
|
243
|
+
packageVersion: "#F6BC9A",
|
|
244
|
+
gitCommit: "#AEE5C5",
|
|
245
|
+
gitMetricsAdded: "#AEE5C5",
|
|
246
|
+
gitMetricsDeleted: "#FF8FA3",
|
|
247
|
+
username: "#F3D98B",
|
|
248
|
+
time: "#F3D98B",
|
|
249
|
+
os: "#F7EEF8",
|
|
250
|
+
muted: "#A99BAE",
|
|
251
|
+
};
|
|
252
|
+
|
|
229
253
|
export const defaultConfig: PolishedTuiConfig = {
|
|
230
254
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
231
255
|
footerFormat: "",
|
|
@@ -239,29 +263,29 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
239
263
|
...NERD_DEFAULT_ICONS,
|
|
240
264
|
},
|
|
241
265
|
colors: {
|
|
242
|
-
cwd: "bold
|
|
243
|
-
gitBranch: "bold
|
|
244
|
-
gitStatus: "bold
|
|
245
|
-
contextNormal: "
|
|
246
|
-
contextWarning: "bold
|
|
247
|
-
contextError: "bold
|
|
248
|
-
tokens: "
|
|
249
|
-
cost: "bold
|
|
250
|
-
separator: "
|
|
251
|
-
runtimePrefix: "
|
|
252
|
-
extensionStatus: "
|
|
253
|
-
sessionDuration: "
|
|
254
|
-
packageVersion: "
|
|
255
|
-
gitCommit: "
|
|
256
|
-
gitMetricsAdded: "
|
|
257
|
-
gitMetricsDeleted: "
|
|
258
|
-
username: "
|
|
259
|
-
time: "
|
|
260
|
-
os: "
|
|
261
|
-
muted: "
|
|
266
|
+
cwd: "bold accent",
|
|
267
|
+
gitBranch: "bold syntaxVariable",
|
|
268
|
+
gitStatus: "bold warning",
|
|
269
|
+
contextNormal: "success",
|
|
270
|
+
contextWarning: "bold warning",
|
|
271
|
+
contextError: "bold error",
|
|
272
|
+
tokens: "muted",
|
|
273
|
+
cost: "bold success",
|
|
274
|
+
separator: "dim",
|
|
275
|
+
runtimePrefix: "syntaxFunction",
|
|
276
|
+
extensionStatus: "customMessageLabel",
|
|
277
|
+
sessionDuration: "syntaxNumber",
|
|
278
|
+
packageVersion: "syntaxType",
|
|
279
|
+
gitCommit: "success",
|
|
280
|
+
gitMetricsAdded: "toolDiffAdded",
|
|
281
|
+
gitMetricsDeleted: "toolDiffRemoved",
|
|
282
|
+
username: "syntaxNumber",
|
|
283
|
+
time: "syntaxNumber",
|
|
284
|
+
os: "text",
|
|
285
|
+
muted: "muted",
|
|
262
286
|
},
|
|
263
287
|
colorSources: {
|
|
264
|
-
starship: "
|
|
288
|
+
starship: "theme",
|
|
265
289
|
},
|
|
266
290
|
features: {
|
|
267
291
|
statusLine: true,
|
|
@@ -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
|
-
|
|
27
|
-
|
|
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,
|
|
@@ -26,10 +26,10 @@ import {
|
|
|
26
26
|
formatOsLabel,
|
|
27
27
|
formatPackageVersionSegment,
|
|
28
28
|
formatRuntimeSegment,
|
|
29
|
+
formatRuntimeValue,
|
|
29
30
|
formatTimeLabel,
|
|
30
31
|
formatUsernameHostLabel,
|
|
31
32
|
} from "../telemetry/format.js";
|
|
32
|
-
import { resolveRuntimeSymbol } from "../ui/icons.js";
|
|
33
33
|
import type { LiveContextOverride } from "../session/live-context.js";
|
|
34
34
|
import type { FooterState } from "../state/index.js";
|
|
35
35
|
import { renderStyleForSource } from "../ui/style.js";
|
|
@@ -180,12 +180,8 @@ export function installFooter(
|
|
|
180
180
|
return statusBlock;
|
|
181
181
|
case "git_state":
|
|
182
182
|
return gitStateBlock;
|
|
183
|
-
case "runtime":
|
|
184
|
-
|
|
185
|
-
const symbol = resolveRuntimeSymbol(safeRuntime.name, safeRuntime.symbol, iconMode);
|
|
186
|
-
const label = safeRuntime.version ? `${symbol} ${safeRuntime.version}` : symbol;
|
|
187
|
-
return renderStyleForSource(theme, colorSource, safeRuntime.style, label);
|
|
188
|
-
}
|
|
183
|
+
case "runtime":
|
|
184
|
+
return formatRuntimeValue(theme, safeRuntime, colorSource, iconMode);
|
|
189
185
|
case "session_duration":
|
|
190
186
|
return state.sessionStartEpoch
|
|
191
187
|
? renderStyleForSource(
|
|
@@ -13,6 +13,7 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
13
13
|
import type { ColorSource, PolishedTuiConfig } from "../config/config.js";
|
|
14
14
|
import { formatSkillCounts } from "../segments/skill-activity.js";
|
|
15
15
|
import type { FooterState } from "../state/index.js";
|
|
16
|
+
import type { RuntimeInfo } from "../segments/runtime.js";
|
|
16
17
|
import { getTelemetryStats } from "../state/telemetry.js";
|
|
17
18
|
import { buildSessionDurationLabel, formatCount } from "../telemetry/format.js";
|
|
18
19
|
import { renderStyleForSource, type ThemeLike } from "../ui/style.js";
|
|
@@ -25,8 +26,7 @@ export function safeStatusText(value: string | null | undefined, maxLength = 256
|
|
|
25
26
|
|
|
26
27
|
export type SanitizedFooterInputs = {
|
|
27
28
|
safeCwd: string;
|
|
28
|
-
safeRuntime:
|
|
29
|
-
{ name: string; symbol: string; style: string; version: string | undefined } | undefined;
|
|
29
|
+
safeRuntime: RuntimeInfo | undefined;
|
|
30
30
|
safePackageVersion: { ecosystem: string; version: string } | undefined;
|
|
31
31
|
safeCommit: { oid: string | null; detached: boolean; tag: string | null } | undefined;
|
|
32
32
|
};
|
package/extensions/hud/index.ts
CHANGED
|
@@ -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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
};
|
|
@@ -240,6 +240,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
240
240
|
name: "php",
|
|
241
241
|
symbol: "",
|
|
242
242
|
style: "147 bold",
|
|
243
|
+
themeStyle: "bold syntaxVariable",
|
|
243
244
|
priority: PRIORITY_COMMON,
|
|
244
245
|
detect: { files: ["composer.json"] },
|
|
245
246
|
version: async () =>
|
|
@@ -265,6 +266,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
265
266
|
name: "cpp",
|
|
266
267
|
symbol: "",
|
|
267
268
|
style: "bold 149",
|
|
269
|
+
themeStyle: "bold success",
|
|
268
270
|
priority: PRIORITY_DEFAULT,
|
|
269
271
|
detect: { extensions: ["cpp", "cc", "cxx", "c++", "hpp", "hh", "hxx", "h++", "tcc"] },
|
|
270
272
|
version: versionFromCommands([
|
|
@@ -277,6 +279,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
277
279
|
name: "c",
|
|
278
280
|
symbol: "",
|
|
279
281
|
style: "bold 149",
|
|
282
|
+
themeStyle: "bold success",
|
|
280
283
|
priority: PRIORITY_DEFAULT,
|
|
281
284
|
detect: { extensions: ["c", "h"] },
|
|
282
285
|
version: versionFromCommands([
|
|
@@ -414,6 +417,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
414
417
|
name: "gleam",
|
|
415
418
|
symbol: "",
|
|
416
419
|
style: "bold #FFAFF3",
|
|
420
|
+
themeStyle: "bold syntaxKeyword",
|
|
417
421
|
priority: PRIORITY_DEFAULT,
|
|
418
422
|
detect: { extensions: ["gleam"], files: ["gleam.toml"] },
|
|
419
423
|
version: versionFromCommands([{ command: "gleam", args: ["--version"] }]),
|
|
@@ -438,6 +442,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
438
442
|
name: "haxe",
|
|
439
443
|
symbol: "",
|
|
440
444
|
style: "bold fg:202",
|
|
445
|
+
themeStyle: "bold syntaxType",
|
|
441
446
|
priority: PRIORITY_DEFAULT,
|
|
442
447
|
detect: {
|
|
443
448
|
extensions: ["hx", "hxml"],
|
|
@@ -482,6 +487,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
482
487
|
name: "mojo",
|
|
483
488
|
symbol: "",
|
|
484
489
|
style: "bold 208",
|
|
490
|
+
themeStyle: "bold syntaxType",
|
|
485
491
|
priority: PRIORITY_DEFAULT,
|
|
486
492
|
detect: { extensions: ["mojo", "🔥"] },
|
|
487
493
|
version: versionFromCommands([{ command: "mojo", args: ["--version"] }]),
|
|
@@ -534,6 +540,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
534
540
|
name: "perl",
|
|
535
541
|
symbol: "",
|
|
536
542
|
style: "bold 149",
|
|
543
|
+
themeStyle: "bold success",
|
|
537
544
|
priority: PRIORITY_DEFAULT,
|
|
538
545
|
detect: {
|
|
539
546
|
extensions: ["pl", "pm", "pod"],
|
|
@@ -564,6 +571,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
564
571
|
name: "pulumi",
|
|
565
572
|
symbol: "",
|
|
566
573
|
style: "bold 5",
|
|
574
|
+
themeStyle: "bold syntaxKeyword",
|
|
567
575
|
priority: PRIORITY_DEFAULT,
|
|
568
576
|
detect: { files: ["Pulumi.yaml", "Pulumi.yml"] },
|
|
569
577
|
version: versionFromCommands([{ command: "pulumi", args: ["version"] }]),
|
|
@@ -580,6 +588,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
580
588
|
name: "raku",
|
|
581
589
|
symbol: "",
|
|
582
590
|
style: "bold 149",
|
|
591
|
+
themeStyle: "bold success",
|
|
583
592
|
priority: PRIORITY_DEFAULT,
|
|
584
593
|
detect: { extensions: ["p6", "pm6", "pod6", "raku", "rakumod"], files: ["META6.json"] },
|
|
585
594
|
version: versionFromCommands([{ command: "raku", args: ["--version"] }]),
|
|
@@ -636,6 +645,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
636
645
|
name: "swift",
|
|
637
646
|
symbol: "",
|
|
638
647
|
style: "bold 202",
|
|
648
|
+
themeStyle: "bold syntaxType",
|
|
639
649
|
priority: PRIORITY_DEFAULT,
|
|
640
650
|
detect: { extensions: ["swift"], files: ["Package.swift"] },
|
|
641
651
|
version: versionFromCommands([
|
|
@@ -646,6 +656,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
646
656
|
name: "terraform",
|
|
647
657
|
symbol: "",
|
|
648
658
|
style: "bold 105",
|
|
659
|
+
themeStyle: "bold syntaxVariable",
|
|
649
660
|
priority: PRIORITY_DEFAULT,
|
|
650
661
|
detect: { extensions: ["tf", "tfplan", "tfstate"], folders: [".terraform"] },
|
|
651
662
|
version: versionFromCommands([
|
|
@@ -657,6 +668,7 @@ const runtimes: RuntimeDef[] = [
|
|
|
657
668
|
name: "typst",
|
|
658
669
|
symbol: "",
|
|
659
670
|
style: "bold #0093A7",
|
|
671
|
+
themeStyle: "bold syntaxFunction",
|
|
660
672
|
priority: PRIORITY_DEFAULT,
|
|
661
673
|
detect: { extensions: ["typ"], files: ["template.typ"] },
|
|
662
674
|
version: versionFromCommands([{ command: "typst", args: ["--version"] }]),
|
|
@@ -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";
|
|
@@ -6,41 +7,43 @@ export type RuntimeMetadata = {
|
|
|
6
7
|
name: string;
|
|
7
8
|
symbol: string;
|
|
8
9
|
style: string;
|
|
10
|
+
/** Semantic alternative for built-in RGB/indexed terminal styles. */
|
|
11
|
+
themeStyle?: string;
|
|
9
12
|
};
|
|
10
13
|
|
|
11
|
-
export type RuntimeInfo =
|
|
14
|
+
export type RuntimeInfo = RuntimeMetadata & {
|
|
12
15
|
version?: string;
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
export type RuntimeReadResult = { kind: "ok"; runtime?: RuntimeInfo } | { kind: "error" };
|
|
16
19
|
|
|
17
20
|
type RuntimeCacheEntry = {
|
|
18
|
-
|
|
19
|
-
|
|
21
|
+
result: RuntimeReadResult;
|
|
22
|
+
expiresAt: number;
|
|
20
23
|
};
|
|
21
24
|
|
|
22
25
|
const RUNTIME_CACHE_MAX = 32;
|
|
26
|
+
const RUNTIME_CACHE_TTL_MS = 60_000;
|
|
27
|
+
const RUNTIME_RETRY_TTL_MS = 5_000;
|
|
23
28
|
const runtimeInfoCache = new Map<string, RuntimeCacheEntry>();
|
|
29
|
+
const runtimeReadsInFlight = new Map<string, Promise<RuntimeReadResult>>();
|
|
30
|
+
let cacheEpoch = 0;
|
|
24
31
|
|
|
25
32
|
export function clearRuntimeInfoCache(): void {
|
|
33
|
+
cacheEpoch += 1;
|
|
26
34
|
runtimeInfoCache.clear();
|
|
35
|
+
// New callers must not join a probe started before the clear.
|
|
36
|
+
runtimeReadsInFlight.clear();
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
function topLevelFingerprint(entries: readonly string[]): string {
|
|
30
40
|
return entries.slice().sort().join("\0");
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
function cacheRuntimeInfo(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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 });
|
|
43
|
+
function cacheRuntimeInfo(key: string, result: RuntimeReadResult): void {
|
|
44
|
+
const ttl =
|
|
45
|
+
result.kind === "ok" && result.runtime?.version ? RUNTIME_CACHE_TTL_MS : RUNTIME_RETRY_TTL_MS;
|
|
46
|
+
runtimeInfoCache.set(key, { result, expiresAt: Date.now() + ttl });
|
|
44
47
|
while (runtimeInfoCache.size > RUNTIME_CACHE_MAX) {
|
|
45
48
|
const oldest = runtimeInfoCache.keys().next().value;
|
|
46
49
|
if (oldest === undefined) break;
|
|
@@ -113,10 +116,11 @@ function matchesDetection(
|
|
|
113
116
|
const sortedRuntimes = runtimeDefinitions;
|
|
114
117
|
|
|
115
118
|
export const runtimeMetadata: RuntimeMetadata[] = runtimeDefinitions.map(
|
|
116
|
-
({ name, symbol, style }) => ({
|
|
119
|
+
({ name, symbol, style, themeStyle }) => ({
|
|
117
120
|
name,
|
|
118
121
|
symbol,
|
|
119
122
|
style,
|
|
123
|
+
themeStyle,
|
|
120
124
|
}),
|
|
121
125
|
);
|
|
122
126
|
|
|
@@ -140,28 +144,54 @@ export async function readRuntimeInfo(cwd: string): Promise<RuntimeReadResult> {
|
|
|
140
144
|
return { kind: "error" };
|
|
141
145
|
}
|
|
142
146
|
|
|
143
|
-
|
|
144
|
-
|
|
147
|
+
// Detection uses shell variables and version commands inherit the environment.
|
|
148
|
+
// Include all variables (including PATH and toolchain-manager settings) so a
|
|
149
|
+
// changed environment never reuses the old probe. JSON avoids key collisions.
|
|
150
|
+
const env = { ...process.env };
|
|
151
|
+
const environment = Object.keys(env)
|
|
152
|
+
.sort()
|
|
153
|
+
.map((key) => [key, env[key]]);
|
|
154
|
+
// Hash environment values so cache keys do not retain plaintext credentials.
|
|
155
|
+
const environmentKey = createHash("sha256").update(JSON.stringify(environment)).digest("hex");
|
|
156
|
+
const cacheKey = JSON.stringify([cwd, topLevelFingerprint(entries), environmentKey]);
|
|
145
157
|
const cached = runtimeInfoCache.get(cacheKey);
|
|
146
|
-
if (cached
|
|
147
|
-
|
|
158
|
+
if (cached) {
|
|
159
|
+
if (Date.now() < cached.expiresAt) return cached.result;
|
|
160
|
+
runtimeInfoCache.delete(cacheKey);
|
|
148
161
|
}
|
|
162
|
+
const inFlight = runtimeReadsInFlight.get(cacheKey);
|
|
163
|
+
if (inFlight) return inFlight;
|
|
149
164
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return { kind: "ok", runtime: undefined };
|
|
165
|
+
const epoch = cacheEpoch;
|
|
166
|
+
const read = async (): Promise<RuntimeReadResult> => {
|
|
167
|
+
try {
|
|
168
|
+
const runtime = detectRuntime(cwd, entries, env);
|
|
169
|
+
if (!runtime) return { kind: "ok", runtime: undefined };
|
|
170
|
+
return {
|
|
171
|
+
kind: "ok",
|
|
172
|
+
runtime: {
|
|
173
|
+
name: runtime.name,
|
|
174
|
+
symbol: runtime.symbol,
|
|
175
|
+
style: runtime.style,
|
|
176
|
+
themeStyle: runtime.themeStyle,
|
|
177
|
+
version: await runtime.version(cwd),
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
} catch {
|
|
181
|
+
return { kind: "error" };
|
|
155
182
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
183
|
+
};
|
|
184
|
+
const pending = read()
|
|
185
|
+
.then((result) => {
|
|
186
|
+
// Clearing the cache must also invalidate outstanding writers.
|
|
187
|
+
if (epoch === cacheEpoch) cacheRuntimeInfo(cacheKey, result);
|
|
188
|
+
return result;
|
|
189
|
+
})
|
|
190
|
+
.finally(() => {
|
|
191
|
+
if (runtimeReadsInFlight.get(cacheKey) === pending) {
|
|
192
|
+
runtimeReadsInFlight.delete(cacheKey);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
runtimeReadsInFlight.set(cacheKey, pending);
|
|
196
|
+
return pending;
|
|
167
197
|
}
|
|
@@ -119,13 +119,18 @@ export class SkillActivityTracker {
|
|
|
119
119
|
}
|
|
120
120
|
continue;
|
|
121
121
|
}
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
) {
|
|
127
|
-
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -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 ({
|
|
47
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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 });
|
|
@@ -288,17 +304,29 @@ export function buildContextLabel(ctx: ExtensionContext): string {
|
|
|
288
304
|
return formatContextPercentLabel(usage?.percent, contextWindow);
|
|
289
305
|
}
|
|
290
306
|
|
|
291
|
-
|
|
307
|
+
/** Render just the runtime value for both default rows and custom templates. */
|
|
308
|
+
export function formatRuntimeValue(
|
|
292
309
|
theme: Pick<Theme, "fg">,
|
|
293
310
|
runtime: RuntimeInfo | undefined,
|
|
294
|
-
prefixStyle: ColorSpec,
|
|
295
311
|
colorSource: ColorSource,
|
|
296
312
|
mode: IconMode = "auto",
|
|
297
313
|
): string {
|
|
298
314
|
if (!runtime) return "";
|
|
299
315
|
const symbol = resolveRuntimeSymbol(runtime.name, runtime.symbol, mode);
|
|
300
316
|
const label = runtime.version ? `${symbol} ${runtime.version}` : symbol;
|
|
301
|
-
|
|
317
|
+
const style = colorSource === "theme" ? (runtime.themeStyle ?? runtime.style) : runtime.style;
|
|
318
|
+
return renderStyleForSource(theme, colorSource, style, label);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function formatRuntimeSegment(
|
|
322
|
+
theme: Pick<Theme, "fg">,
|
|
323
|
+
runtime: RuntimeInfo | undefined,
|
|
324
|
+
prefixStyle: ColorSpec,
|
|
325
|
+
colorSource: ColorSource,
|
|
326
|
+
mode: IconMode = "auto",
|
|
327
|
+
): string {
|
|
328
|
+
if (!runtime) return "";
|
|
329
|
+
return `${renderStyleForSource(theme, colorSource, prefixStyle, "via")} ${formatRuntimeValue(theme, runtime, colorSource, mode)}`;
|
|
302
330
|
}
|
|
303
331
|
|
|
304
332
|
/**
|
|
@@ -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
|
-
|
|
168
|
-
if (trimmed === "") return true;
|
|
169
|
-
return trimmed.split(/\s+/).every(isSupportedStyleToken);
|
|
173
|
+
return parseStyle(style).supported;
|
|
170
174
|
}
|
|
171
175
|
|
|
172
|
-
function applyThemeModifiers(
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
240
|
-
if (
|
|
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
|
-
|
|
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.
|
|
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
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
"version": "1.5.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-
|
|
43
|
-
"@earendil-works/pi-
|
|
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": "
|
|
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",
|