pi-editor-footer 0.8.0 → 0.12.1

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 (57) hide show
  1. package/CHANGELOG.md +43 -1
  2. package/dist/agent-run-ledger.js +149 -13
  3. package/dist/border-renderer.js +10 -16
  4. package/dist/chrome-composition.js +1 -1
  5. package/dist/chrome-state.js +10 -5
  6. package/dist/config.js +3 -3
  7. package/dist/cursor-policy.js +3 -5
  8. package/dist/detail-chrome.js +117 -2
  9. package/dist/detail-render.js +83 -7
  10. package/dist/footer.js +22 -11
  11. package/dist/git.js +7 -11
  12. package/dist/header.js +2 -5
  13. package/dist/layout.js +2 -4
  14. package/dist/live-border.js +12 -10
  15. package/dist/model-info.js +2 -8
  16. package/dist/path-format.js +1 -2
  17. package/dist/run-activity.js +2 -6
  18. package/dist/runtime.js +1 -7
  19. package/dist/session-orchestrator.js +119 -29
  20. package/dist/telemetry.js +8 -28
  21. package/dist/theme-settings.js +2 -4
  22. package/dist/tip-policy.js +2 -4
  23. package/dist/tracking-editor.js +8 -3
  24. package/dist/transcript-timeline.js +57 -36
  25. package/dist/utils.js +4 -4
  26. package/dist/window-presentation.js +30 -1
  27. package/package.json +23 -4
  28. package/src/agent-run-ledger.ts +172 -48
  29. package/src/border-renderer.ts +32 -52
  30. package/src/chrome-composition.ts +2 -8
  31. package/src/chrome-state.ts +15 -23
  32. package/src/color-policy.ts +1 -3
  33. package/src/config.ts +13 -28
  34. package/src/cursor-policy.ts +11 -20
  35. package/src/detail-chrome.ts +113 -14
  36. package/src/detail-render.ts +139 -75
  37. package/src/footer.ts +37 -44
  38. package/src/git.ts +9 -26
  39. package/src/header.ts +6 -23
  40. package/src/icons.ts +1 -2
  41. package/src/index.ts +45 -65
  42. package/src/layout.ts +7 -23
  43. package/src/live-border.ts +10 -14
  44. package/src/model-info.ts +182 -213
  45. package/src/path-format.ts +1 -2
  46. package/src/run-activity.ts +8 -25
  47. package/src/runtime.ts +9 -25
  48. package/src/session-orchestrator.ts +146 -139
  49. package/src/state.ts +4 -9
  50. package/src/telemetry.ts +19 -65
  51. package/src/theme-settings.ts +20 -62
  52. package/src/tip-policy.ts +5 -9
  53. package/src/tracking-editor.ts +21 -21
  54. package/src/transcript-timeline.ts +112 -79
  55. package/src/utils.ts +33 -4
  56. package/src/window-presentation.ts +51 -26
  57. package/dist/session-kernel.js +0 -178
package/CHANGELOG.md CHANGED
@@ -1,8 +1,50 @@
1
+ ## [0.12.1](https://github.com/Rianico/pi-editor-footer/compare/v0.12.0...v0.12.1) (2026-09-07)
2
+
3
+ ### Bug Fixes
4
+
5
+ * add .npmrc and clean allowScripts for npm install-scripts ([fc362cb](https://github.com/Rianico/pi-editor-footer/commit/fc362cbb654dbf886f92f774e2a5e87aa4ec5914))
6
+
7
+ ## [0.12.0](https://github.com/Rianico/pi-editor-footer/compare/v0.11.0...v0.12.0) (2026-09-07)
8
+
9
+ ### Features
10
+
11
+ * **detail:** show model invocation badge to the right of skill path ([0f03593](https://github.com/Rianico/pi-editor-footer/commit/0f0359347d7ae3de740400c587c5b0ff4a6fb57e))
12
+ * **detail:** use distinct highlight for human vs model badge ([e242df1](https://github.com/Rianico/pi-editor-footer/commit/e242df14975e10b6bfd32f8a48e5f943d7579864))
13
+
14
+ ### Bug Fixes
15
+
16
+ * allow pnpm builds for esbuild and genai ([36e139e](https://github.com/Rianico/pi-editor-footer/commit/36e139e9443ef928a4366c3d7c8afe494b6e0850))
17
+ * make prepare tolerant to missing husky for pi install ([23ebb62](https://github.com/Rianico/pi-editor-footer/commit/23ebb62cd8d732656fd113da27c807ffa34579fc))
18
+
1
19
  # Changelog
2
20
 
3
21
  All notable changes to this project will be documented in this file.
4
22
 
5
- ## [Unreleased]
23
+ ## [0.11.0] - 2026-08-29
24
+
25
+ ### Changed
26
+
27
+ - Release workflow trigger clarified — tag `v*` remains the release trigger (semantic-release on tag push), reverted tentative `main` trigger
28
+
29
+ ## [0.10.0] - 2026-08-28
30
+
31
+ ### Added
32
+
33
+ - Detail header shows slash command path dimmed at right of type — workspace-relative/~/ tildified, middle-truncated with ellipsis, hidden when <10 cols remain, auto-resolved for skill items across project and home candidates with fallback to global symlink path
34
+
35
+ ### Fixed
36
+
37
+ - Resolve pre-existing blocking diagnostics (`require-safety-comment`, `no-unknown-returns`, `zizmor cache-poisoning`) to keep lens clean
38
+
39
+ ## [0.9.0] - 2026-08-27
40
+
41
+ ### Added
42
+
43
+ - Incremental per-agent_run live input accounting — `trigger+Σ(outputs+tools)+liveDelta` per run (`~` during live, authoritative `deltaFromBaseline` after), `AgentRunLedger` owns `triggerTokens/accumOutput/accumTool` reset in `startRun`, `getIncrementalLiveDisplayTotals`/`getIdleAuthoritativeDisplay` (grill Q6→a Q7→b Q8→a Q9→a Q11→a)
44
+
45
+ ### Changed
46
+
47
+ - `turn_start` input now synthetic `trigger+Σ` (independent per run) instead of `getContextUsage` window; `tool_result`/`tool_execution_end` fold `chars/4` into accumulation; `LiveBorder` top `↑` hybrid (`~` live, no `~` idle), context bar stays real window (Q10); co-coded with `typescript-expert` (`unknown` boundaries, `SAFETY` on pi seams)
6
48
 
7
49
  ## [0.8.0] - 2026-08-26
8
50
 
@@ -15,11 +15,29 @@
15
15
  * / cap helpers) hides baseline delta, max-vs-sum, predictive vs authoritative capping,
16
16
  * and tps derivation. Two adapters (LiveBorder token bar, index timeline) justify the seam.
17
17
  * Internal seams (aggregateTurns, capIdle, capLive) stay private.
18
+ *
19
+ * Incremental live input (per-agent_run):
20
+ * liveInput = triggerTokens + Σ(completed turn outputs) + Σ(toolResult tokens) + liveDelta
21
+ * Each agent_run resets independently. triggerTokens = ceil(triggerChars/4) or 0 if
22
+ * no user message (Q11 a). Tool results are ceil(chars/4) per tool_result (Q8 a).
23
+ * Live input is predictive (estimated=true, "~" prefix) and capped only to contextWindow.
24
+ * Settled/idle input snaps to authoritative deltaFromBaseline (hybrid Q7 b, Q6 a).
18
25
  */
19
26
  function round(value, decimals) {
20
27
  const factor = 10 ** decimals;
21
28
  return Math.round(value * factor) / factor;
22
29
  }
30
+ /** Estimate tokens from char length — ~4 chars/token, same as TurnTelemetryTracker live estimate. */
31
+ export function estimateTokensFromChars(chars) {
32
+ if (!Number.isFinite(chars) || chars <= 0)
33
+ return 0;
34
+ return Math.ceil(chars / 4);
35
+ }
36
+ export function estimateTokensFromText(text) {
37
+ if (typeof text !== "string" || text.length === 0)
38
+ return 0;
39
+ return estimateTokensFromChars(text.length);
40
+ }
23
41
  /**
24
42
  * Pure aggregation: max(input) + sum(output/cost/stalls) across turns, plus optional live turn.
25
43
  * Shared by TurnTelemetryTracker.peekAgentLive / endAgent and ledger — single source for
@@ -65,11 +83,9 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
65
83
  ttftMs = live.ttftMs;
66
84
  }
67
85
  totalTokens = inputTokens + outputTokens;
68
- const totalMs = startMs !== null ? Math.max(0, now - startMs) : 0;
86
+ const totalMs = startMs === null ? 0 : Math.max(0, now - startMs);
69
87
  const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
70
- const tps = measurementMs === null
71
- ? null
72
- : round(outputTokens / (measurementMs / 1000), 1);
88
+ const tps = measurementMs === null ? null : round(outputTokens / (measurementMs / 1000), 1);
73
89
  const validCost = Number.isFinite(costUsd) && costUsd > 0;
74
90
  const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
75
91
  return {
@@ -80,9 +96,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
80
96
  outputTokens,
81
97
  stallMs,
82
98
  stallCount,
83
- rateUsdPerMTokens: validCost && validTokens
84
- ? round(costUsd / (totalTokens / 1_000_000), 2)
85
- : null,
99
+ rateUsdPerMTokens: validCost && validTokens ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
86
100
  generationMs,
87
101
  totalTokens,
88
102
  costUsd: validCost ? costUsd : 0,
@@ -99,9 +113,7 @@ export function aggregateAgentTurns(turns, live, startMs, now) {
99
113
  */
100
114
  export function capInputForIdle(input, contextTokens, sessionTotalInput) {
101
115
  let capped = input;
102
- if (typeof contextTokens === "number" &&
103
- Number.isFinite(contextTokens) &&
104
- contextTokens > 0) {
116
+ if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) {
105
117
  capped = Math.min(capped, contextTokens);
106
118
  }
107
119
  if (sessionTotalInput > 0) {
@@ -111,9 +123,7 @@ export function capInputForIdle(input, contextTokens, sessionTotalInput) {
111
123
  }
112
124
  export function capInputForLive(input, contextTokens) {
113
125
  let capped = input;
114
- if (typeof contextTokens === "number" &&
115
- Number.isFinite(contextTokens) &&
116
- contextTokens > 0) {
126
+ if (typeof contextTokens === "number" && Number.isFinite(contextTokens) && contextTokens > 0) {
117
127
  capped = Math.min(capped, contextTokens);
118
128
  }
119
129
  return Math.max(0, capped);
@@ -131,6 +141,10 @@ export class AgentRunLedger {
131
141
  turns = [];
132
142
  startMs = null;
133
143
  now;
144
+ // Incremental per-agent_run accumulation (Q1/Q6/Q9)
145
+ triggerTokens = 0;
146
+ accumOutputTokens = 0;
147
+ accumToolTokens = 0;
134
148
  constructor(now = () => Date.now()) {
135
149
  this.now = now;
136
150
  }
@@ -144,10 +158,52 @@ export class AgentRunLedger {
144
158
  startRun(startMs) {
145
159
  this.startMs = typeof startMs === "number" ? startMs : this.now();
146
160
  this.turns = [];
161
+ this.triggerTokens = 0;
162
+ this.accumOutputTokens = 0;
163
+ this.accumToolTokens = 0;
164
+ }
165
+ /** Set trigger message tokens for this agent_run (chars/4, or 0 per Q11 a). */
166
+ setTriggerTokens(tokens) {
167
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens < 0)
168
+ return;
169
+ this.triggerTokens = Math.round(tokens);
170
+ }
171
+ getTriggerTokens() {
172
+ return this.triggerTokens;
173
+ }
174
+ /** Add tool result tokens (chars/4) to accumulation (Q8 a). */
175
+ addToolResultTokens(tokens) {
176
+ if (typeof tokens !== "number" || !Number.isFinite(tokens) || tokens <= 0)
177
+ return;
178
+ this.accumToolTokens += Math.round(tokens);
179
+ }
180
+ getAccumOutputTokens() {
181
+ return this.accumOutputTokens;
182
+ }
183
+ getAccumToolTokens() {
184
+ return this.accumToolTokens;
185
+ }
186
+ /** Synthetic completed input without live delta: trigger + Σ(outputs) + Σ(tools). */
187
+ getSyntheticCompletedInput() {
188
+ return this.triggerTokens + this.accumOutputTokens + this.accumToolTokens;
189
+ }
190
+ /** Synthetic live input including live delta: trigger + Σ(outputs+tools) + liveDelta. */
191
+ getSyntheticLiveInput(liveDeltaTokens) {
192
+ const d = typeof liveDeltaTokens === "number" && Number.isFinite(liveDeltaTokens) ? liveDeltaTokens : 0;
193
+ return (this.triggerTokens +
194
+ this.accumOutputTokens +
195
+ this.accumToolTokens +
196
+ Math.max(0, Math.round(d)));
147
197
  }
148
198
  /** Record a completed turn's telemetry (called by telemetryTracker or directly). */
149
199
  recordTurn(turn) {
150
200
  this.turns.push({ ...turn });
201
+ // Accumulate authoritative output for synthetic input (trigger + Σ outputs)
202
+ if (typeof turn.outputTokens === "number" &&
203
+ Number.isFinite(turn.outputTokens) &&
204
+ turn.outputTokens > 0) {
205
+ this.accumOutputTokens += Math.round(turn.outputTokens);
206
+ }
151
207
  }
152
208
  /** Settled totals without live turn — used at agent_settled for timeline. */
153
209
  getSettledTotals(now) {
@@ -231,9 +287,46 @@ export class AgentRunLedger {
231
287
  estimated: false,
232
288
  };
233
289
  }
290
+ /**
291
+ * Idle authoritative display (hybrid Q7 b): snapshot delta capped, for use when
292
+ * not running to show billed total without ~ after agent_end.
293
+ */
294
+ getIdleAuthoritativeDisplay(snapshotTotals, contextTokens) {
295
+ let displayInput;
296
+ let displayOutput;
297
+ let displayCost;
298
+ if (this.baseline) {
299
+ const d = deltaFromBaseline(snapshotTotals, this.baseline);
300
+ displayInput = d.input;
301
+ displayOutput = d.output;
302
+ displayCost = d.cost;
303
+ }
304
+ else {
305
+ displayInput = snapshotTotals.input;
306
+ displayOutput = snapshotTotals.output;
307
+ displayCost = snapshotTotals.cost;
308
+ }
309
+ const cappedInput = capInputForIdle(displayInput, contextTokens, snapshotTotals.input);
310
+ return {
311
+ tps: null,
312
+ ttftMs: 0,
313
+ totalMs: 0,
314
+ inputTokens: cappedInput,
315
+ outputTokens: displayOutput,
316
+ stallMs: 0,
317
+ stallCount: 0,
318
+ rateUsdPerMTokens: null,
319
+ generationMs: 0,
320
+ totalTokens: cappedInput + displayOutput,
321
+ costUsd: displayCost,
322
+ measurementMs: null,
323
+ estimated: false,
324
+ };
325
+ }
234
326
  /**
235
327
  * Live display totals when running: liveTurn input (current window) replaces
236
328
  * agentLive input, output/cost remain per-agent sum, capped to live context only.
329
+ * @deprecated — retained for backward compat; prefer getIncrementalLiveDisplayTotals for Q1/Q9.
237
330
  */
238
331
  getLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
239
332
  if (!agentLive && !liveTurn)
@@ -260,10 +353,53 @@ export class AgentRunLedger {
260
353
  estimated: true,
261
354
  };
262
355
  }
356
+ /**
357
+ * Incremental live display (Q1/Q6/Q9): trigger + Σ(outputs+tools) + liveDelta, capped to live.
358
+ * Replaces max(input) with synthetic accumulation, per-agent_run independent.
359
+ */
360
+ getIncrementalLiveDisplayTotals(liveTurn, agentLive, contextTokens) {
361
+ if (!agentLive && !liveTurn)
362
+ return null;
363
+ const liveDelta = liveTurn?.outputTokens ?? 0;
364
+ const syntheticInput = this.getSyntheticLiveInput(liveDelta);
365
+ const cappedInput = capInputForLive(syntheticInput, contextTokens);
366
+ // Output/cost/stalls come from agentLive (sum) when available, else liveTurn
367
+ const base = agentLive ?? liveTurn;
368
+ if (!base)
369
+ return null;
370
+ // When both exist, agentLive already sums outputs, so use it; else liveTurn
371
+ const displayOutput = agentLive ? agentLive.outputTokens : liveTurn.outputTokens;
372
+ const displayCost = agentLive ? agentLive.costUsd : liveTurn.costUsd;
373
+ const displayStallMs = agentLive ? agentLive.stallMs : liveTurn.stallMs;
374
+ const displayStallCount = agentLive ? agentLive.stallCount : liveTurn.stallCount;
375
+ const displayGenerationMs = agentLive ? agentLive.generationMs : liveTurn.generationMs;
376
+ const displayTtft = agentLive ? agentLive.ttftMs : liveTurn.ttftMs;
377
+ const displayTps = agentLive ? agentLive.tps : liveTurn.tps;
378
+ const displayTotalMs = agentLive ? agentLive.totalMs : liveTurn.totalMs;
379
+ const displayMeasurementMs = agentLive ? agentLive.measurementMs : liveTurn.measurementMs;
380
+ return {
381
+ tps: displayTps,
382
+ ttftMs: displayTtft,
383
+ totalMs: displayTotalMs,
384
+ inputTokens: cappedInput,
385
+ outputTokens: displayOutput,
386
+ stallMs: displayStallMs,
387
+ stallCount: displayStallCount,
388
+ rateUsdPerMTokens: base.rateUsdPerMTokens,
389
+ generationMs: displayGenerationMs,
390
+ totalTokens: cappedInput + displayOutput,
391
+ costUsd: displayCost,
392
+ measurementMs: displayMeasurementMs,
393
+ estimated: true,
394
+ };
395
+ }
263
396
  reset() {
264
397
  this.baseline = null;
265
398
  this.turns = [];
266
399
  this.startMs = null;
400
+ this.triggerTokens = 0;
401
+ this.accumOutputTokens = 0;
402
+ this.accumToolTokens = 0;
267
403
  }
268
404
  isActive() {
269
405
  return this.startMs !== null;
@@ -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,7 +30,8 @@ export class BorderRenderer {
30
30
  const info = this.getModelInfo();
31
31
  const glow = (s) => {
32
32
  try {
33
- const maybeGlow = theme.getThinkingBorderColor;
33
+ const maybeGlow = // SAFETY: intentional unsafe cast — validated at runtime
34
+ theme.getThinkingBorderColor;
34
35
  if (typeof maybeGlow === "function")
35
36
  return maybeGlow.call(theme, info.level)(s);
36
37
  }
@@ -79,7 +80,7 @@ export class BorderRenderer {
79
80
  if (tokensLeft) {
80
81
  // Use | as separator between ↑/↓ and tool turns (dimmed) per user request
81
82
  // SAFETY: pi theme seam — fg is optional theme method
82
- const themeForTokens = this.getLiveTheme();
83
+ const themeForTokens = this.getLiveTheme(); // SAFETY: intentional unsafe cast — validated at runtime
83
84
  const fg = themeForTokens.fg;
84
85
  const dimPipe = typeof fg === "function" ? fg.call(themeForTokens, "dim", " | ") : " | ";
85
86
  const combined = tokensRight ? `${tokensLeft}${dimPipe}${tokensRight}` : tokensLeft;
@@ -91,7 +92,8 @@ export class BorderRenderer {
91
92
  const theme = this.getLiveTheme();
92
93
  const glow = (s) => {
93
94
  try {
94
- const maybeGlow = theme.getThinkingBorderColor;
95
+ const maybeGlow = // SAFETY: intentional unsafe cast — validated at runtime
96
+ theme.getThinkingBorderColor;
95
97
  if (typeof maybeGlow === "function")
96
98
  return maybeGlow.call(theme, this.getModelInfo().level)(s);
97
99
  }
@@ -132,12 +134,8 @@ function embedTopWithLeftAndRight(lines, width, leftText, rightText, getGlow) {
132
134
  displayLeft = truncateToWidth(leftText, maxLeft, "");
133
135
  if (rightW > maxRight)
134
136
  displayRight = truncateToWidth(rightText, maxRight, "");
135
- const leftSegment = displayLeft
136
- ? `${getGlow("─")} ${displayLeft} `
137
- : getGlow("─");
138
- const rightSegment = displayRight
139
- ? ` ${displayRight} ${getGlow("─")}`
140
- : getGlow("─");
137
+ const leftSegment = displayLeft ? `${getGlow("─")} ${displayLeft} ` : getGlow("─");
138
+ const rightSegment = displayRight ? ` ${displayRight} ${getGlow("─")}` : getGlow("─");
141
139
  const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
142
140
  const middleWidth = Math.max(0, width - used);
143
141
  const middle = getGlow("─".repeat(middleWidth));
@@ -170,12 +168,8 @@ function embedBottomBorder(lines, width, leftText, rightText, getGlow) {
170
168
  displayLeft = truncateToWidth(leftText, maxLeft, "");
171
169
  if (rightW > maxRight)
172
170
  displayRight = truncateToWidth(rightText, maxRight, "");
173
- const leftSegment = displayLeft
174
- ? `${getGlow("─")} ${displayLeft} `
175
- : getGlow("─");
176
- const rightSegment = displayRight
177
- ? ` ${displayRight} ${getGlow("─")}`
178
- : getGlow("─");
171
+ const leftSegment = displayLeft ? `${getGlow("─")} ${displayLeft} ` : getGlow("─");
172
+ const rightSegment = displayRight ? ` ${displayRight} ${getGlow("─")}` : getGlow("─");
179
173
  const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
180
174
  const middleWidth = Math.max(0, width - used);
181
175
  const middle = getGlow("─".repeat(middleWidth));
@@ -20,7 +20,7 @@
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
24
  /**
25
25
  * Read the live pi theme into a typed { fg } surface. The cast is a SAFETY seam — a pi theme
26
26
  * missing `fg` degrades to identity rather than throwing, keeping the chrome resilient.
@@ -45,9 +45,7 @@ export function formatContextBar(contextUsage, theme, glyphs, isAscii, barWidth
45
45
  const base = showIconBar
46
46
  ? `${theme.fg(contextColor, glyphs.context)} ${renderBar(theme, contextPct, barWidth, isAscii)} ${baseCore}`
47
47
  : baseCore;
48
- const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate)
49
- ? cacheHitRate
50
- : 0;
48
+ const rate = cacheHitRate !== undefined && Number.isFinite(cacheHitRate) ? cacheHitRate : 0;
51
49
  const cacheText = `${glyphs.cacheHit} ${rate.toFixed(1)}%`;
52
50
  return `${theme.fg(cacheHitColor(rate), cacheText)} ${theme.fg("dim", "|")} ${base}`;
53
51
  }
@@ -66,13 +64,17 @@ export function formatTopContextFromSnapshot(snapshot, theme, glyphs, isAscii, s
66
64
  export function createChromeSnapshot(ctx, footerState) {
67
65
  const cwd = ctx?.sessionManager?.getCwd?.() ??
68
66
  // SAFETY: pi seam — intentional unsafe cast, validated at runtime
69
- ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
67
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
68
+ // SAFETY: intentional unsafe cast — validated at runtime
69
+ /* SAFETY: intentional unsafe cast — validated at runtime */ ctx?.cwd ?? // SAFETY: pi seam — intentional unsafe cast, validated at runtime
70
70
  process.cwd();
71
71
  const sessionName = ctx?.sessionManager?.getSessionName?.();
72
72
  const contextUsage = ctx?.getContextUsage?.();
73
73
  const totals = getUsageTotals(
74
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
74
75
  // SAFETY: pi seam
75
- (ctx ?? {}));
76
+ /* SAFETY: intentional unsafe cast — validated at runtime */ (ctx ??
77
+ {}));
76
78
  // footerState may be absent when called from LiveBorder (context-only); use empty defaults
77
79
  // SAFETY: pi seam
78
80
  const git = footerState?.git ??
@@ -88,6 +90,9 @@ export function createChromeSnapshot(ctx, footerState) {
88
90
  renamed: 0,
89
91
  deleted: 0,
90
92
  commit: null,
93
+ // ast-grep-ignore: require-safety-comment-for-as-unknown-as
94
+ // SAFETY: intentional unsafe cast — validated at runtime
95
+ /* SAFETY: intentional unsafe cast — validated at runtime */
91
96
  }; // SAFETY: pi seam — intentional unsafe cast, validated at runtime
92
97
  const runtime = footerState?.runtime ?? null;
93
98
  return {
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 };
@@ -99,6 +97,7 @@ export const CONFIG_SCHEMA = [
99
97
  { path: "timeline.tokens", kind: "boolean" },
100
98
  { path: "timeline.cost", kind: "boolean" },
101
99
  ];
100
+ // SAFETY: table-driven config access — path validated against CONFIG_SCHEMA, caller validates via validate()
102
101
  function getByPath(obj, path) {
103
102
  const parts = path.split(".");
104
103
  let cur = obj;
@@ -123,6 +122,7 @@ function setByPath(obj, path, value) {
123
122
  }
124
123
  cur[parts[parts.length - 1]] = value;
125
124
  }
125
+ // SAFETY: table-driven config access — path validated against CONFIG_SCHEMA, caller validates via validate()
126
126
  function getDefaultByPath(path) {
127
127
  return getByPath(DEFAULT_CONFIG, path);
128
128
  }
@@ -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
  }
@@ -1,4 +1,22 @@
1
- import { contentLineCount, renderDetail, scroll, } from "./detail-render.js";
1
+ /**
2
+ * DetailChrome — deep module owning the whole Detail-window flow behind one seam.
3
+ *
4
+ * Previously the Highlight → Detail window flow was fragmented: TrackingEditor
5
+ * observed the Candidate highlight, src/index.ts held widget state
6
+ * (currentItem/scrollOffset/lastWidth), scroll math, and header-marker
7
+ * re-parsing (contentLinesFrom); src/detail-render.ts owned wrapping;
8
+ * src/window-presentation.ts owned the border. Fixing a wrapping bug required
9
+ * holding 3 modules in one head — no locality.
10
+ *
11
+ * Depth: small interface (setItem / getItem / scrollBy / render) hides scroll
12
+ * offset, width, MAX_LINES, wrapping, border, and header-marker parsing inside.
13
+ * detail-render + window-presentation are INTERNAL seams, not part of the
14
+ * interface. One place to learn — one place to test, no TUI needed.
15
+ */
16
+ import * as fs from "node:fs";
17
+ import * as os from "node:os";
18
+ import * as path from "node:path";
19
+ import { contentLineCount, renderDetail, scroll } from "./detail-render.js";
2
20
  import { decorateWindow } from "./window-presentation.js";
3
21
  /** Height cap of the detail window (the user's spec: up to 5 lines). */
4
22
  export const MAX_LINES = 5;
@@ -6,12 +24,107 @@ export const MAX_LINES = 5;
6
24
  function kindOf(value) {
7
25
  return value.startsWith("skill:") ? "skill" : "command";
8
26
  }
27
+ function resolveSkillPath(skillName) {
28
+ const name = skillName.trim();
29
+ if (!name)
30
+ return undefined;
31
+ const candidates = [];
32
+ const cwd = process.cwd();
33
+ const home = (() => {
34
+ try {
35
+ return os.homedir();
36
+ }
37
+ catch {
38
+ return "";
39
+ }
40
+ })();
41
+ // project-local candidates
42
+ candidates.push(path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md"));
43
+ candidates.push(path.join(cwd, ".agents", "skills", name, "SKILL.md"));
44
+ candidates.push(path.join(cwd, ".pi", "skills", name, "SKILL.md"));
45
+ candidates.push(path.join(cwd, "skills", name, "SKILL.md"));
46
+ // user-global candidates
47
+ if (home) {
48
+ candidates.push(path.join(home, ".pi", "agent", "skills", name, "SKILL.md"));
49
+ candidates.push(path.join(home, ".agents", "skills", name, "SKILL.md"));
50
+ candidates.push(path.join(home, ".pi", ".agents", "skills", name, "SKILL.md"));
51
+ candidates.push(path.join(home, ".pi", "skills", name, "SKILL.md"));
52
+ candidates.push(path.join(home, "stowfiles", "dotfiles", ".pi", "agent", "skills", name, "SKILL.md"));
53
+ candidates.push(path.join(home, "stowfiles", "dotfiles", ".pi", "agent", ".agents", "skills", name, "SKILL.md"));
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
+ }
77
+ for (const p of candidates) {
78
+ try {
79
+ if (fs.existsSync(p))
80
+ return p;
81
+ }
82
+ catch {
83
+ // ignore
84
+ }
85
+ }
86
+ // fallback to global symlink path so header always shows a path for skills
87
+ if (home)
88
+ return path.join(home, ".pi", "agent", "skills", name, "SKILL.md");
89
+ return path.join(cwd, ".pi", "agent", "skills", name, "SKILL.md");
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
+ }
9
106
  function detailItemOf(item) {
10
- return {
107
+ const detail = {
11
108
  label: item.label,
12
109
  kind: kindOf(item.value),
13
110
  description: item.description ?? "",
14
111
  };
112
+ const rawPath = item.path?.trim() ?? "";
113
+ if (rawPath !== "") {
114
+ detail.path = rawPath;
115
+ }
116
+ else if (item.value.startsWith("skill:")) {
117
+ const skillName = item.value.slice("skill:".length);
118
+ const resolved = resolveSkillPath(skillName);
119
+ if (resolved !== undefined)
120
+ detail.path = resolved;
121
+ }
122
+ if (detail.kind === "skill" && detail.path) {
123
+ const inv = parseInvocation(detail.path);
124
+ if (inv)
125
+ detail.invocation = inv;
126
+ }
127
+ return detail;
15
128
  }
16
129
  export class DetailChrome {
17
130
  item = null;
@@ -67,6 +180,8 @@ export class DetailChrome {
67
180
  border: (s) => t.fg("border", s),
68
181
  highlight: (s) => t.fg("accent", t.bold(s)),
69
182
  dim: (s) => t.fg("dim", s),
183
+ model: (s) => t.fg("success", s),
184
+ human: (s) => t.fg("warning", s),
70
185
  };
71
186
  const innerWidth = Math.max(1, width - 4);
72
187
  const lines = renderDetail(detailItemOf(item), innerWidth, this.maxLines, this.scrollOffset);