pi-editor-footer 0.11.0 → 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +26 -2
  2. package/dist/agent-run-ledger.js +9 -27
  3. package/dist/ansi-color.js +142 -0
  4. package/dist/border-renderer.js +9 -19
  5. package/dist/chrome-composition.js +9 -4
  6. package/dist/chrome-state.js +22 -13
  7. package/dist/color-policy.js +31 -18
  8. package/dist/config.js +1 -3
  9. package/dist/cursor-policy.js +3 -5
  10. package/dist/detail-chrome.js +45 -1
  11. package/dist/detail-render.js +20 -8
  12. package/dist/footer.js +10 -7
  13. package/dist/git.js +7 -11
  14. package/dist/header.js +2 -5
  15. package/dist/layout.js +2 -4
  16. package/dist/live-border.js +3 -3
  17. package/dist/model-info.js +10 -122
  18. package/dist/path-format.js +1 -2
  19. package/dist/run-activity.js +2 -6
  20. package/dist/runtime.js +1 -7
  21. package/dist/session-orchestrator.js +10 -15
  22. package/dist/telemetry.js +8 -28
  23. package/dist/theme-settings.js +2 -4
  24. package/dist/tip-policy.js +2 -4
  25. package/dist/transcript-timeline.js +15 -34
  26. package/dist/utils.js +5 -5
  27. package/dist/window-presentation.js +19 -3
  28. package/package.json +16 -5
  29. package/src/agent-run-ledger.ts +19 -71
  30. package/src/ansi-color.ts +186 -0
  31. package/src/border-renderer.ts +32 -54
  32. package/src/chrome-composition.ts +17 -13
  33. package/src/chrome-state.ts +36 -27
  34. package/src/color-policy.ts +35 -18
  35. package/src/config.ts +6 -24
  36. package/src/cursor-policy.ts +11 -20
  37. package/src/detail-chrome.ts +48 -28
  38. package/src/detail-render.ts +132 -125
  39. package/src/footer.ts +30 -44
  40. package/src/git.ts +9 -26
  41. package/src/header.ts +6 -23
  42. package/src/icons.ts +1 -2
  43. package/src/index.ts +45 -65
  44. package/src/layout.ts +13 -23
  45. package/src/live-border.ts +4 -9
  46. package/src/model-info.ts +94 -248
  47. package/src/path-format.ts +1 -2
  48. package/src/run-activity.ts +8 -25
  49. package/src/runtime.ts +9 -25
  50. package/src/session-orchestrator.ts +50 -124
  51. package/src/state.ts +4 -9
  52. package/src/telemetry.ts +19 -65
  53. package/src/theme-settings.ts +20 -62
  54. package/src/tip-policy.ts +5 -9
  55. package/src/tracking-editor.ts +11 -19
  56. package/src/transcript-timeline.ts +23 -77
  57. package/src/utils.ts +37 -6
  58. package/src/window-presentation.ts +51 -38
  59. package/dist/session-kernel.js +0 -178
package/CHANGELOG.md CHANGED
@@ -1,9 +1,33 @@
1
- # Changelog
1
+ ## [0.12.2](https://github.com/Rianico/pi-editor-footer/compare/v0.12.1...v0.12.2) (2026-09-13)
2
2
 
3
- All notable changes to this project will be documented in this file.
3
+ ### Bug Fixes
4
+
5
+ * **context:** tier-color the context window section by usage ([#30](https://github.com/Rianico/pi-editor-footer/issues/30)) ([8152d90](https://github.com/Rianico/pi-editor-footer/commit/8152d90b3c1b4013154eff95cc885e79e7564f6f))
4
6
 
5
7
  ## [Unreleased]
6
8
 
9
+ ## [0.12.1](https://github.com/Rianico/pi-editor-footer/compare/v0.12.0...v0.12.1) (2026-09-07)
10
+
11
+ ### Bug Fixes
12
+
13
+ * add .npmrc and clean allowScripts for npm install-scripts ([fc362cb](https://github.com/Rianico/pi-editor-footer/commit/fc362cbb654dbf886f92f774e2a5e87aa4ec5914))
14
+
15
+ ## [0.12.0](https://github.com/Rianico/pi-editor-footer/compare/v0.11.0...v0.12.0) (2026-09-07)
16
+
17
+ ### Features
18
+
19
+ * **detail:** show model invocation badge to the right of skill path ([0f03593](https://github.com/Rianico/pi-editor-footer/commit/0f0359347d7ae3de740400c587c5b0ff4a6fb57e))
20
+ * **detail:** use distinct highlight for human vs model badge ([e242df1](https://github.com/Rianico/pi-editor-footer/commit/e242df14975e10b6bfd32f8a48e5f943d7579864))
21
+
22
+ ### Bug Fixes
23
+
24
+ * allow pnpm builds for esbuild and genai ([36e139e](https://github.com/Rianico/pi-editor-footer/commit/36e139e9443ef928a4366c3d7c8afe494b6e0850))
25
+ * make prepare tolerant to missing husky for pi install ([23ebb62](https://github.com/Rianico/pi-editor-footer/commit/23ebb62cd8d732656fd113da27c807ffa34579fc))
26
+
27
+ # Changelog
28
+
29
+ All notable changes to this project will be documented in this file.
30
+
7
31
  ## [0.11.0] - 2026-08-29
8
32
 
9
33
  ### Changed
@@ -85,9 +85,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
85
85
  totalTokens = inputTokens + outputTokens;
86
86
  const totalMs = startMs === null ? 0 : Math.max(0, now - startMs);
87
87
  const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
88
- const tps = measurementMs === null
89
- ? null
90
- : round(outputTokens / (measurementMs / 1000), 1);
88
+ const tps = measurementMs === null ? null : round(outputTokens / (measurementMs / 1000), 1);
91
89
  const validCost = Number.isFinite(costUsd) && costUsd > 0;
92
90
  const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
93
91
  return {
@@ -98,9 +96,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
98
96
  outputTokens,
99
97
  stallMs,
100
98
  stallCount,
101
- rateUsdPerMTokens: validCost && validTokens
102
- ? round(costUsd / (totalTokens / 1_000_000), 2)
103
- : null,
99
+ rateUsdPerMTokens: validCost && validTokens ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
104
100
  generationMs,
105
101
  totalTokens,
106
102
  costUsd: validCost ? costUsd : 0,
@@ -117,9 +113,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
117
113
  */
118
114
  export function capInputForIdle(input, contextTokens, sessionTotalInput) {
119
115
  let capped = input;
120
- if (typeof contextTokens === "number" &&
121
- Number.isFinite(contextTokens) &&
122
- contextTokens > 0) {
116
+ if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) {
123
117
  capped = Math.min(capped, contextTokens);
124
118
  }
125
119
  if (sessionTotalInput > 0) {
@@ -129,9 +123,7 @@ export function capInputForIdle(input, contextTokens, sessionTotalInput) {
129
123
  }
130
124
  export function capInputForLive(input, contextTokens) {
131
125
  let capped = input;
132
- if (typeof contextTokens === "number" &&
133
- Number.isFinite(contextTokens) &&
134
- contextTokens > 0) {
126
+ if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) {
135
127
  capped = Math.min(capped, contextTokens);
136
128
  }
137
129
  return Math.max(0, capped);
@@ -197,9 +189,7 @@ export class AgentRunLedger {
197
189
  }
198
190
  /** Synthetic live input including live delta: trigger + Σ(outputs+tools) + liveDelta. */
199
191
  getSyntheticLiveInput(liveDeltaTokens) {
200
- const d = typeof liveDeltaTokens === "number" && Number.isFinite(liveDeltaTokens)
201
- ? liveDeltaTokens
202
- : 0;
192
+ const d = typeof liveDeltaTokens === "number" && Number.isFinite(liveDeltaTokens) ? liveDeltaTokens : 0;
203
193
  return (this.triggerTokens +
204
194
  this.accumOutputTokens +
205
195
  this.accumToolTokens +
@@ -378,23 +368,15 @@ export class AgentRunLedger {
378
368
  if (!base)
379
369
  return null;
380
370
  // When both exist, agentLive already sums outputs, so use it; else liveTurn
381
- const displayOutput = agentLive
382
- ? agentLive.outputTokens
383
- : liveTurn.outputTokens;
371
+ const displayOutput = agentLive ? agentLive.outputTokens : liveTurn.outputTokens;
384
372
  const displayCost = agentLive ? agentLive.costUsd : liveTurn.costUsd;
385
373
  const displayStallMs = agentLive ? agentLive.stallMs : liveTurn.stallMs;
386
- const displayStallCount = agentLive
387
- ? agentLive.stallCount
388
- : liveTurn.stallCount;
389
- const displayGenerationMs = agentLive
390
- ? agentLive.generationMs
391
- : liveTurn.generationMs;
374
+ const displayStallCount = agentLive ? agentLive.stallCount : liveTurn.stallCount;
375
+ const displayGenerationMs = agentLive ? agentLive.generationMs : liveTurn.generationMs;
392
376
  const displayTtft = agentLive ? agentLive.ttftMs : liveTurn.ttftMs;
393
377
  const displayTps = agentLive ? agentLive.tps : liveTurn.tps;
394
378
  const displayTotalMs = agentLive ? agentLive.totalMs : liveTurn.totalMs;
395
- const displayMeasurementMs = agentLive
396
- ? agentLive.measurementMs
397
- : liveTurn.measurementMs;
379
+ const displayMeasurementMs = agentLive ? agentLive.measurementMs : liveTurn.measurementMs;
398
380
  return {
399
381
  tps: displayTps,
400
382
  ttftMs: displayTtft,
@@ -0,0 +1,142 @@
1
+ const BASIC16 = [
2
+ [0, 0, 0],
3
+ [128, 0, 0],
4
+ [0, 128, 0],
5
+ [128, 128, 0],
6
+ [0, 0, 128],
7
+ [128, 0, 128],
8
+ [0, 128, 128],
9
+ [192, 192, 192],
10
+ [128, 128, 128],
11
+ [255, 0, 0],
12
+ [0, 255, 0],
13
+ [255, 255, 0],
14
+ [0, 0, 255],
15
+ [255, 0, 255],
16
+ [0, 255, 255],
17
+ [255, 255, 255],
18
+ ];
19
+ /** 6x6x6 colour-cube channel values (indices 0-5). */
20
+ const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
21
+ /** Grayscale ramp values (indices 232-255: 24 grays from 8 to 238). */
22
+ const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
23
+ /** Anything but the truecolor marker means the 256-colour ramp. */
24
+ export function normalizeColorMode(mode) {
25
+ return mode === "truecolor" ? "truecolor" : "256color";
26
+ }
27
+ export function indexToRgb(n) {
28
+ if (n >= 0 && n < 16) {
29
+ const [r, g, b] = BASIC16[n] ?? [0, 0, 0];
30
+ return { r, g, b };
31
+ }
32
+ if (n >= 16 && n <= 231) {
33
+ const v = n - 16;
34
+ return {
35
+ r: CUBE_VALUES[Math.floor(v / 36)] ?? 0,
36
+ g: CUBE_VALUES[Math.floor(v / 6) % 6] ?? 0,
37
+ b: CUBE_VALUES[v % 6] ?? 0,
38
+ };
39
+ }
40
+ if (n >= 232 && n <= 255) {
41
+ const gray = 8 + (n - 232) * 10;
42
+ return { r: gray, g: gray, b: gray };
43
+ }
44
+ return null;
45
+ }
46
+ function findClosestCubeIndex(value) {
47
+ let minDist = Infinity;
48
+ let minIdx = 0;
49
+ for (let i = 0; i < CUBE_VALUES.length; i++) {
50
+ const dist = Math.abs(value - (CUBE_VALUES[i] ?? 0));
51
+ if (dist < minDist) {
52
+ minDist = dist;
53
+ minIdx = i;
54
+ }
55
+ }
56
+ return minIdx;
57
+ }
58
+ function findClosestGrayIndex(gray) {
59
+ let minDist = Infinity;
60
+ let minIdx = 0;
61
+ for (let i = 0; i < GRAY_VALUES.length; i++) {
62
+ const dist = Math.abs(gray - (GRAY_VALUES[i] ?? 0));
63
+ if (dist < minDist) {
64
+ minDist = dist;
65
+ minIdx = i;
66
+ }
67
+ }
68
+ return minIdx;
69
+ }
70
+ function colorDistance(r1, g1, b1, r2, g2, b2) {
71
+ const dr = r1 - r2;
72
+ const dg = g1 - g2;
73
+ const db = b1 - b2;
74
+ // Weighted Euclidean distance (the eye is more sensitive to green).
75
+ return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
76
+ }
77
+ /** Quantise RGB to the closest xterm-256 index (same rule as the theme loader). */
78
+ export function rgbTo256(r, g, b) {
79
+ const rIdx = findClosestCubeIndex(r);
80
+ const gIdx = findClosestCubeIndex(g);
81
+ const bIdx = findClosestCubeIndex(b);
82
+ const cubeR = CUBE_VALUES[rIdx] ?? 0;
83
+ const cubeG = CUBE_VALUES[gIdx] ?? 0;
84
+ const cubeB = CUBE_VALUES[bIdx] ?? 0;
85
+ const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
86
+ const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
87
+ const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
88
+ const grayIdx = findClosestGrayIndex(gray);
89
+ const grayValue = GRAY_VALUES[grayIdx] ?? 0;
90
+ const grayIndex = 232 + grayIdx;
91
+ const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
92
+ const spread = Math.max(r, g, b) - Math.min(r, g, b);
93
+ // Only consider grayscale when the colour is nearly neutral AND closer.
94
+ if (spread < 10 && grayDist < cubeDist)
95
+ return grayIndex;
96
+ return cubeIndex;
97
+ }
98
+ /** Parse `#RRGGBB`. Throws on malformed input — callers pass literals, not user data. */
99
+ export function hexToRgb(hex) {
100
+ const cleaned = hex.startsWith("#") ? hex.slice(1) : hex;
101
+ if (cleaned.length !== 6)
102
+ throw new Error(`Invalid hex color: ${hex}`);
103
+ const { r, g, b } = {
104
+ r: parseInt(cleaned.slice(0, 2), 16),
105
+ g: parseInt(cleaned.slice(2, 4), 16),
106
+ b: parseInt(cleaned.slice(4, 6), 16),
107
+ };
108
+ if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
109
+ throw new Error(`Invalid hex color: ${hex}`);
110
+ }
111
+ return { r, g, b };
112
+ }
113
+ /** Parse a theme token's ANSI escape back into RGB (truecolor or 256 index). */
114
+ export function parseFgAnsiToRgb(theme, color) {
115
+ const ansi = theme.getFgAnsi(color);
116
+ const trueColor = ansi.match(/38;2;(\d+);(\d+);(\d+)/);
117
+ if (trueColor) {
118
+ return {
119
+ r: Number(trueColor[1]),
120
+ g: Number(trueColor[2]),
121
+ b: Number(trueColor[3]),
122
+ };
123
+ }
124
+ const palette = ansi.match(/38;5;(\d+)/);
125
+ if (palette)
126
+ return indexToRgb(Number(palette[1]));
127
+ return null;
128
+ }
129
+ /** SGR foreground sequence for an RGB value at the terminal's colour fidelity. */
130
+ export function rgbToFgAnsi({ r, g, b }, mode) {
131
+ return normalizeColorMode(mode) === "truecolor"
132
+ ? `\x1b[38;2;${r};${g};${b}m`
133
+ : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
134
+ }
135
+ /**
136
+ * Colour `s` with an exact hex, bypassing the theme's named-token lookup.
137
+ * Returned painter resets only the foreground (`\x1b[39m`), matching `theme.fg`.
138
+ */
139
+ export function hexFg(hex, mode) {
140
+ const ansi = rgbToFgAnsi(hexToRgb(hex), mode);
141
+ return (s) => `${ansi}${s}\x1b[39m`;
142
+ }
@@ -1,5 +1,5 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
- import { buildLabel, } from "./model-info.js";
2
+ import { applyModelInfo, buildLabel } from "./model-info.js";
3
3
  function stripAnsi(s) {
4
4
  return s.replace(/\x1b\[[0-9;]*m/g, "");
5
5
  }
@@ -30,9 +30,8 @@ export class BorderRenderer {
30
30
  const info = this.getModelInfo();
31
31
  const glow = (s) => {
32
32
  try {
33
- const maybeGlow =
34
- // SAFETY: intentional unsafe cast — validated at runtime
35
- theme.getThinkingBorderColor;
33
+ const maybeGlow = // SAFETY: intentional unsafe cast — validated at runtime
34
+ theme.getThinkingBorderColor;
36
35
  if (typeof maybeGlow === "function")
37
36
  return maybeGlow.call(theme, info.level)(s);
38
37
  }
@@ -93,9 +92,8 @@ export class BorderRenderer {
93
92
  const theme = this.getLiveTheme();
94
93
  const glow = (s) => {
95
94
  try {
96
- const maybeGlow =
97
- // SAFETY: intentional unsafe cast — validated at runtime
98
- theme.getThinkingBorderColor;
95
+ const maybeGlow = // SAFETY: intentional unsafe cast — validated at runtime
96
+ theme.getThinkingBorderColor;
99
97
  if (typeof maybeGlow === "function")
100
98
  return maybeGlow.call(theme, this.getModelInfo().level)(s);
101
99
  }
@@ -136,12 +134,8 @@ function embedTopWithLeftAndRight(lines, width, leftText, rightText, getGlow) {
136
134
  displayLeft = truncateToWidth(leftText, maxLeft, "");
137
135
  if (rightW > maxRight)
138
136
  displayRight = truncateToWidth(rightText, maxRight, "");
139
- const leftSegment = displayLeft
140
- ? `${getGlow("─")} ${displayLeft} `
141
- : getGlow("─");
142
- const rightSegment = displayRight
143
- ? ` ${displayRight} ${getGlow("─")}`
144
- : getGlow("─");
137
+ const leftSegment = displayLeft ? `${getGlow("─")} ${displayLeft} ` : getGlow("─");
138
+ const rightSegment = displayRight ? ` ${displayRight} ${getGlow("─")}` : getGlow("─");
145
139
  const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
146
140
  const middleWidth = Math.max(0, width - used);
147
141
  const middle = getGlow("─".repeat(middleWidth));
@@ -174,12 +168,8 @@ function embedBottomBorder(lines, width, leftText, rightText, getGlow) {
174
168
  displayLeft = truncateToWidth(leftText, maxLeft, "");
175
169
  if (rightW > maxRight)
176
170
  displayRight = truncateToWidth(rightText, maxRight, "");
177
- const leftSegment = displayLeft
178
- ? `${getGlow("─")} ${displayLeft} `
179
- : getGlow("─");
180
- const rightSegment = displayRight
181
- ? ` ${displayRight} ${getGlow("─")}`
182
- : getGlow("─");
171
+ const leftSegment = displayLeft ? `${getGlow("─")} ${displayLeft} ` : getGlow("─");
172
+ const rightSegment = displayRight ? ` ${displayRight} ${getGlow("─")}` : getGlow("─");
183
173
  const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
184
174
  const middleWidth = Math.max(0, width - used);
185
175
  const middle = getGlow("─".repeat(middleWidth));
@@ -20,15 +20,20 @@
20
20
  import { resolveGlyphs, resolveIconMode } from "./icons.js";
21
21
  import { formatTopContextFromSnapshot } from "./chrome-state.js";
22
22
  import { formatRunActivityTopRight } from "./run-activity.js";
23
- import { formatTelemetryTokens, formatTurnDuration, formatTurnTelemetry, } from "./telemetry.js";
23
+ import { formatTelemetryTokens, formatTurnDuration, formatTurnTelemetry } from "./telemetry.js";
24
+ import { hexFg } from "./ansi-color.js";
24
25
  /**
25
- * Read the live pi theme into a typed { fg } surface. The cast is a SAFETY seam — a pi theme
26
- * missing `fg` degrades to identity rather than throwing, keeping the chrome resilient.
26
+ * Read the live pi theme into a typed { fg, fgHex } surface. The cast is a SAFETY
27
+ * seam — a pi theme missing `fg` degrades to identity rather than throwing, keeping
28
+ * the chrome resilient. `fgHex` needs no theme call at all: the named-token lookup
29
+ * rejects raw hex, so hex is rendered straight to SGR at the theme's colour mode.
27
30
  */
28
31
  export function adaptTheme(rawTheme) {
29
- const t = rawTheme; // SAFETY: pi theme seam — fg is optional, guarded below
32
+ const t = rawTheme;
33
+ const mode = typeof t.getColorMode === "function" ? t.getColorMode() : undefined;
30
34
  return {
31
35
  fg: (style, s) => (typeof t.fg === "function" ? t.fg(style, s) : s),
36
+ fgHex: (hex, s) => hexFg(hex, mode)(s),
32
37
  };
33
38
  }
34
39
  /** Optional thinking-border glow (pi theme extension). Falls back to identity. */
@@ -15,20 +15,30 @@
15
15
  * inside this module, not part of its external seam.
16
16
  */
17
17
  import { getUsageTotals } from "./state.js";
18
- import { cacheHitColor, contextUsageColor } from "./color-policy.js";
18
+ import { cacheHitColor, CONTEXT_TIER_HEX, CONTEXT_TIER_THEME_COLOR, contextUsageTier, } from "./color-policy.js";
19
19
  import { fmtTokens } from "./format.js";
20
20
  // ---------------------------------------------------------------------------
21
21
  // Context bar formatting — moved from footer.ts to centralize chrome rendering.
22
22
  // Re-exported from footer.ts for backward compatibility.
23
23
  // ---------------------------------------------------------------------------
24
- function renderBar(theme, pct, barWidth, ascii) {
24
+ /**
25
+ * Painter for one context tier: exact hex when the live theme can emit raw hex,
26
+ * else the nearest semantic token (tests/mocks, themes without colour mode).
27
+ */
28
+ function contextPainter(theme, tier) {
29
+ const hex = CONTEXT_TIER_HEX[tier];
30
+ const fgHex = theme.fgHex;
31
+ if (fgHex)
32
+ return (s) => fgHex.call(theme, hex, s);
33
+ return (s) => theme.fg(CONTEXT_TIER_THEME_COLOR[tier], s);
34
+ }
35
+ function renderBar(theme, paint, pct, barWidth, ascii) {
25
36
  const filled = Math.max(0, Math.min(barWidth, Math.round((pct / 100) * barWidth)));
26
37
  const empty = barWidth - filled;
27
- const color = contextUsageColor(pct);
28
38
  const filledCell = ascii ? "#" : "█";
29
39
  const emptyCell = ascii ? "-" : "░";
30
40
  return (theme.fg("dim", "[") +
31
- theme.fg(color, filledCell.repeat(filled)) +
41
+ paint(filledCell.repeat(filled)) +
32
42
  theme.fg("dim", emptyCell.repeat(empty)) +
33
43
  theme.fg("dim", "]"));
34
44
  }
@@ -37,17 +47,15 @@ export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth
37
47
  if (contextWindow <= 0)
38
48
  return "";
39
49
  const contextPct = contextUsage?.percent ?? 0;
40
- const contextColor = contextUsageColor(contextPct);
41
- const pctText = theme.fg(contextColor, `${contextPct.toFixed(1)}%`);
50
+ const paint = contextPainter(theme, contextUsageTier(contextPct, contextWindow));
51
+ const pctText = paint(`${contextPct.toFixed(1)}%`);
42
52
  const contextTokens = contextUsage?.tokens ?? 0;
43
- const ctxText = `${theme.fg(contextColor, fmtTokens(contextTokens))}${theme.fg("dim", "/")}${theme.fg(contextColor, fmtTokens(contextWindow))}`;
53
+ const ctxText = `${paint(fmtTokens(contextTokens))}${theme.fg("dim", "/")}${paint(fmtTokens(contextWindow))}`;
44
54
  const baseCore = `${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
45
55
  const base = showIconBar
46
- ? `${theme.fg(contextColor, glyphs.context)} ${renderBar(theme, contextPct, barWidth, isAscii)} ${baseCore}`
56
+ ? `${paint(glyphs.context)} ${renderBar(theme, paint, contextPct, barWidth, isAscii)} ${baseCore}`
47
57
  : baseCore;
48
- const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate)
49
- ? cacheHitRate
50
- : 0;
58
+ const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate) ? cacheHitRate : 0;
51
59
  const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
52
60
  return `${theme.fg(cacheHitColor(rate), cacheText)} ${theme.fg("dim", "|")} ${base}`;
53
61
  }
@@ -75,7 +83,8 @@ export function createChromeSnapshot(ctx, footerState) {
75
83
  const totals = getUsageTotals(
76
84
  // ast-grep-ignore: require-safety-comment-for-as-unknown-as
77
85
  // SAFETY: pi seam
78
- /* SAFETY: intentional unsafe cast — validated at runtime */ (ctx ?? {}));
86
+ /* SAFETY: intentional unsafe cast — validated at runtime */ (ctx ??
87
+ {}));
79
88
  // footerState may be absent when called from LiveBorder (context-only); use empty defaults
80
89
  // SAFETY: pi seam
81
90
  const git = footerState?.git ??
@@ -93,7 +102,7 @@ export function createChromeSnapshot(ctx, footerState) {
93
102
  commit: null,
94
103
  // ast-grep-ignore: require-safety-comment-for-as-unknown-as
95
104
  // SAFETY: intentional unsafe cast — validated at runtime
96
- /* SAFETY: intentional unsafe cast — validated at runtime */
105
+ /* SAFETY: intentional unsafe cast — validated at runtime */
97
106
  }; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
98
107
  const runtime = footerState?.runtime ?? null;
99
108
  return {
@@ -5,24 +5,37 @@ export function stressColor(value, warn = 70, danger = 90) {
5
5
  return "warning";
6
6
  return "accent";
7
7
  }
8
- export function contextUsageColor(pct) {
9
- // 12.5 / 25 / 50 quotas four urgency tiers, increasingly aggressive as context fills.
10
- // 0 12.5% dim — plenty of headroom, visually quiet.
11
- // 12.5 25% accent — first nudge, noticeable but calm.
12
- // 25 50% warning — half consumed, needs attention.
13
- // 50 – 100% error — critical, about to run out.
14
- // Uses theme semantic tokens (dim/accent/warning/error) so the progression
15
- // respects the active theme and remains legible on light/dark/custom palettes.
16
- // A fixed hex palette (e.g. grey→sky→amber→red) would be more vivid but
17
- // would ignore the user's theme and can clash with light backgrounds —
18
- // semantic tokens keep the "aggressive" ordering while staying theme-coherent.
19
- if (pct >= 50)
20
- return "error";
21
- if (pct >= 25)
22
- return "warning";
23
- if (pct >= 12.5)
24
- return "accent";
25
- return "dim";
8
+ /**
9
+ * Fixed tier palette (nord green / nord yellow / dark red). Hex, not theme tokens:
10
+ * the tiers are an alarm scale the user picked explicitly, so they must read the
11
+ * same on every theme. `CONTEXT_TIER_THEME_COLOR` is only the fallback for themes
12
+ * that cannot emit raw hex.
13
+ */
14
+ export const CONTEXT_TIER_HEX = {
15
+ ok: "#A3BE8C",
16
+ warn: "#EBCB8B",
17
+ critical: "#9A3939",
18
+ };
19
+ /** Semantic token approximating each tier — used when raw hex is unavailable. */
20
+ export const CONTEXT_TIER_THEME_COLOR = {
21
+ ok: "success",
22
+ warn: "warning",
23
+ critical: "error",
24
+ };
25
+ /**
26
+ * Tier from the share of the context window consumed.
27
+ *
28
+ * Budgets scale with the window: a 1M window only earns 12.5 / 25 % of slack
29
+ * before the same alarm, while smaller windows (compacted far sooner) keep the
30
+ * looser 25 / 50 % steps.
31
+ */
32
+ export function contextUsageTier(pct, contextWindow) {
33
+ const [warnAt, criticalAt] = contextWindow >= 1_000_000 ? [12.5, 25] : [25, 50];
34
+ if (pct >= criticalAt)
35
+ return "critical";
36
+ if (pct >= warnAt)
37
+ return "warn";
38
+ return "ok";
26
39
  }
27
40
  export function cacheHitColor(value) {
28
41
  if (value < 30)
package/dist/config.js CHANGED
@@ -46,9 +46,7 @@ function deepMerge(base, override) {
46
46
  if (typeof base !== "object" || base === null || Array.isArray(base)) {
47
47
  return override ?? base;
48
48
  }
49
- if (typeof override !== "object" ||
50
- override === null ||
51
- Array.isArray(override)) {
49
+ if (typeof override !== "object" || override === null || Array.isArray(override)) {
52
50
  return base;
53
51
  }
54
52
  const result = { ...base };
@@ -17,15 +17,13 @@ function removeSoftwareCursor(line, cursorMarker = "") {
17
17
  function configureCursor(tui, cursorStyle) {
18
18
  if (cursorStyle === "block")
19
19
  return;
20
- const setShow =
21
- // SAFETY: intentional unsafe cast — validated at runtime
22
- tui.setShowHardwareCursor;
20
+ const setShow = // SAFETY: intentional unsafe cast — validated at runtime
21
+ tui.setShowHardwareCursor;
23
22
  if (typeof setShow === "function")
24
23
  setShow.call(tui, true);
25
24
  const seq = CURSOR_STYLE_SEQUENCES[cursorStyle];
26
25
  // SAFETY: intentional unsafe cast — validated at runtime
27
- const term = tui
28
- .terminal;
26
+ const term = tui.terminal;
29
27
  if (seq && term && typeof term.write === "function")
30
28
  term.write(seq);
31
29
  }
@@ -16,7 +16,7 @@
16
16
  import * as fs from "node:fs";
17
17
  import * as os from "node:os";
18
18
  import * as path from "node:path";
19
- import { contentLineCount, renderDetail, scroll, } from "./detail-render.js";
19
+ import { contentLineCount, renderDetail, scroll } from "./detail-render.js";
20
20
  import { decorateWindow } from "./window-presentation.js";
21
21
  /** Height cap of the detail window (the user's spec: up to 5 lines). */
22
22
  export const MAX_LINES = 5;
@@ -52,6 +52,28 @@ function resolveSkillPath(skillName) {
52
52
  candidates.push(path.join(home, "stowfiles", "dotfiles", ".pi", "agent", "skills", name, "SKILL.md"));
53
53
  candidates.push(path.join(home, "stowfiles", "dotfiles", ".pi", "agent", ".agents", "skills", name, "SKILL.md"));
54
54
  }
55
+ // subskill candidates (any parent skill -> subskills/<name>)
56
+ const bases = [
57
+ path.join(cwd, ".agents", "skills"),
58
+ path.join(cwd, ".pi", "agent", "skills"),
59
+ path.join(cwd, ".pi", "skills"),
60
+ path.join(cwd, "skills"),
61
+ ];
62
+ if (home) {
63
+ bases.push(path.join(home, ".agents", "skills"), path.join(home, ".pi", "agent", "skills"), path.join(home, ".pi", "skills"));
64
+ }
65
+ for (const base of bases) {
66
+ try {
67
+ const entries = fs.readdirSync(base, { withFileTypes: true });
68
+ for (const e of entries) {
69
+ const isDir = e.isDirectory() || e.isSymbolicLink();
70
+ if (!isDir)
71
+ continue;
72
+ candidates.push(path.join(base, e.name, "subskills", name, "SKILL.md"));
73
+ }
74
+ }
75
+ catch { }
76
+ }
55
77
  for (const p of candidates) {
56
78
  try {
57
79
  if (fs.existsSync(p))
@@ -66,6 +88,21 @@ function resolveSkillPath(skillName) {
66
88
  return path.join(home, ".pi", "agent", "skills", name, "SKILL.md");
67
89
  return path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md");
68
90
  }
91
+ function parseInvocation(skillPath) {
92
+ try {
93
+ const content = fs.readFileSync(skillPath, "utf-8");
94
+ const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
95
+ if (!fmMatch)
96
+ return "model";
97
+ const fm = fmMatch[1] ?? "";
98
+ if (/^\s*disable-model-invocation\s*:\s*true\b/m.test(fm))
99
+ return "human";
100
+ return "model";
101
+ }
102
+ catch {
103
+ return undefined;
104
+ }
105
+ }
69
106
  function detailItemOf(item) {
70
107
  const detail = {
71
108
  label: item.label,
@@ -82,6 +119,11 @@ function detailItemOf(item) {
82
119
  if (resolved !== undefined)
83
120
  detail.path = resolved;
84
121
  }
122
+ if (detail.kind === "skill" && detail.path) {
123
+ const inv = parseInvocation(detail.path);
124
+ if (inv)
125
+ detail.invocation = inv;
126
+ }
85
127
  return detail;
86
128
  }
87
129
  export class DetailChrome {
@@ -138,6 +180,8 @@ export class DetailChrome {
138
180
  border: (s) => t.fg("border", s),
139
181
  highlight: (s) => t.fg("accent", t.bold(s)),
140
182
  dim: (s) => t.fg("dim", s),
183
+ model: (s) => t.fg("success", s),
184
+ human: (s) => t.fg("warning", s),
141
185
  };
142
186
  const innerWidth = Math.max(1, width - 4);
143
187
  const lines = renderDetail(detailItemOf(item), innerWidth, this.maxLines, this.scrollOffset);
@@ -42,9 +42,11 @@ export function renderDetail(item, width, maxLines, scrollOffset) {
42
42
  const markerLen = marker.length;
43
43
  let header;
44
44
  const rawPath = item.path?.trim() ?? "";
45
+ const invocationSeg = item.invocation ? ` · ${item.invocation}` : "";
46
+ const invocationLen = invocationSeg.length;
45
47
  if (rawPath !== "") {
46
48
  const normalized = normalizePath(rawPath);
47
- const availableForPath = Math.max(0, wrapWidth - base.length - markerLen - 2);
49
+ const availableForPath = Math.max(0, wrapWidth - base.length - markerLen - invocationLen - 2);
48
50
  let pathSeg = "";
49
51
  if (availableForPath >= 10) {
50
52
  const truncated = normalized.length <= availableForPath
@@ -52,19 +54,30 @@ export function renderDetail(item, width, maxLines, scrollOffset) {
52
54
  : truncateMiddle(normalized, availableForPath);
53
55
  pathSeg = ` ${truncated}`;
54
56
  }
55
- const totalLen = base.length + pathSeg.length + markerLen;
57
+ const totalLen = base.length + pathSeg.length + invocationLen + markerLen;
56
58
  if (totalLen <= wrapWidth) {
57
- header = base + pathSeg + marker;
59
+ header = base + pathSeg + invocationSeg + marker;
58
60
  }
59
- else if (pathSeg !== "") {
60
- const baseWidth = Math.max(0, wrapWidth - pathSeg.length - markerLen);
61
- header = truncateToWidth(base, baseWidth) + pathSeg + marker;
61
+ else if (pathSeg !== "" || invocationSeg !== "") {
62
+ const suffixLen = pathSeg.length + invocationLen + markerLen;
63
+ const baseWidth = Math.max(0, wrapWidth - suffixLen);
64
+ header = truncateToWidth(base, baseWidth) + pathSeg + invocationSeg + marker;
62
65
  }
63
66
  else {
64
67
  const nameWidth = Math.max(0, wrapWidth - markerLen);
65
68
  header = truncateToWidth(base, nameWidth) + marker;
66
69
  }
67
70
  }
71
+ else if (invocationSeg !== "") {
72
+ const totalLen = base.length + invocationLen + markerLen;
73
+ if (totalLen <= wrapWidth) {
74
+ header = base + invocationSeg + marker;
75
+ }
76
+ else {
77
+ const baseWidth = Math.max(0, wrapWidth - invocationLen - markerLen);
78
+ header = truncateToWidth(base, baseWidth) + invocationSeg + marker;
79
+ }
80
+ }
68
81
  else if (overflows) {
69
82
  const nameWidth = Math.max(0, wrapWidth - markerLen);
70
83
  header = truncateToWidth(base, nameWidth) + marker;
@@ -83,8 +96,7 @@ export function contentLineCount(item, width) {
83
96
  if (!item || item.description.trim() === "") {
84
97
  return 0;
85
98
  }
86
- return wrapDescription(item.description, Math.max(1, Math.floor(width)))
87
- .length;
99
+ return wrapDescription(item.description, Math.max(1, Math.floor(width))).length;
88
100
  }
89
101
  /**
90
102
  * Next scroll offset after moving by `delta` (-1 = back/up, +1 = forward/down).