pi-editor-footer 0.1.2 → 0.3.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/CHANGELOG.md CHANGED
@@ -4,20 +4,70 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.3.0] - 2026-08-21
8
+ ### Changed
9
+ - Timeline format now two-line dim per user spec: `2026-08-21 13:48:46 GMT+8 · 11s · ↑ 495 · ↓ 708 · c 85.3% · $0.00` + `3 turns · 5 tools · 1 failed` (datetime with timezone via `Intl` `en-CA` `short` TZ, wall `formatDuration`, `↑`/`↓` `fmtTokens`, cache `glyphs.cacheHit` `latestCacheHitRate`, cost `$`, turn/tools/failed from `runActivity` snapshot)
10
+ - Dimmed timeline now in transcript between runs — `User: hello? / Assistant: hi. / <dim timeline>` left-aligned dim, injected into `chatContainer` (scrollable) via `InteractiveMode` patch, not `aboveEditor` widget, so it sits between each `User/Assistant` pair and scrolls with history
11
+ - Dimmed timeline between each agent run in transcript — `chatContainer` injection `User/Assistant/<dim timeline>` left-aligned dim `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` via `InteractiveMode` patch (not `aboveEditor` widget), one per `agent_settled` per `timeline.*`, permanent in scroll history, never exposed to model (between transcript bottom and input), one per `agent_settled` (`· 8s wall · ↑ 1.2k · ↓ 800 · $0.12`), permanent between runs, never exposed to model (pi-tui only exposes `aboveEditor`/`belowEditor` widgets, so stacking aboveEditor is the non-exposing compromise)
12
+ - Wall time dim line left aligned and permanent between `agent_end` and next `agent_start` (`aboveEditor` `· 8s wall · ↑` dim left, not centered, stays in gap, never exposed)
13
+ - Dim line after `agent_end` now with specified timeline metrics (`timeline.enabled`/`wallTime`/`tokens`/`cost` in `ThemeConfig`, `Timeline` tab in `/pi-editor-footer` settings) — `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim aboveEditor, never exposed to model
14
+ - Wall time dim line after `agent_end` now positioned after last agent message in transcript (`aboveEditor` widget `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim, not footer second line) — visible right after `agent_end` message, hidden on next `agent_start`, never exposed to model
15
+ - Wall time dim line after `agent_end` now a separate footer row (was inline `cwd · wall`) — now `line1: cwd · runtime | ↑·↓·$` and `line2: · 8s wall · ↑ 1.2k · ↓ 800 · $0.12` dim, so timeline is visible
16
+ - Tokens separator now `·` not `|` (`↑ 1.2k · ↓ 800 · $0.12` in footer stats and `· 8s wall · ↑ · ↓ · $` dim line)
17
+ - Settings window title aligned to repo name `pi-editor-footer Settings` (was `pi-lsz-theme Settings`) and slash command renamed `pi-footer` → `pi-editor-footer`
18
+ - Tokens/cost relocated from telemetry bottom to wall time dim line — bottom now `TPS 4.0 tok/s · TTFT 4.0s · 5.0s` (no `↑`/`↓`/`$`), dim line after `agent_end` is `· 8s wall · ↑ 1.2k · ↓ 800 · $0.12` (all `dim`, TUI-only, never exposed to model, via `FooterState.lastDoneIn` + `getUsageTotals` gated by `telemetry.tokens`/`cost`)
19
+ - Git branch never folded — `renderGitSegment` no longer `truncateBranch(…,20)`, full `feature/very-long-…` shown; priority `6` (> cwd `5`) so `fitSegmentsByPriority` never omits branch before cwd/runtime/stats
20
+ - Telemetry TPS display throttled to 1 s (data vs display separated) — `message_update` now only updates `liveDeltaChars`/`liveEstimatedTokens` in `TurnTelemetryTracker`, display `peekLive()` → `formatTurnTelemetry` only via `liveTickTimer` every `REFRESH_MS` and `message_start`/`end`/`turn_end`/`agent_settled` for approximately accurate TPS without per-delta TUI jank
21
+ - Telemetry cost toggling `telemetry.cost` off/on no longer resets cost to `$0.00` — `refreshLiveTelemetry` now falls back to `getLastTelemetry()` when `peekLive()` is null (idle) and `onConfigChanged` calls `refreshLiveTelemetry()` immediately
22
+ - Refresh rate set to one second (`REFRESH_MS = 1000` for `liveTickTimer` telemetry/top/context and `watchTimer` editor ownership watchdog, was hardcoded `1000`)
23
+ - Context icon bar now switchable via config `contextIconBar` (default `false` disabled) — `0.0% · 0/1.0M | c 0.0%` by default, `# [████░░] 39.6% · 416k/1.0M | c 85.3%` when enabled (footer top, `nerd`/`ascii` glyphs)
24
+ - Context bar format now `0.0% · 0/1.0M | c 0.0%` (was `# [#####-------] 39.6% · 416k/1.0M | c`; no icon/bar, compact `pct · tokens/W`)
25
+ - Context bar and cache now follow icon mode (`nerd` ``/``/`█` vs `ascii` ` #`/`c`/`#`) — `refreshContextBar` called in `onConfigChanged` so top ` # [...] | c` updates immediately when `icons.mode` changes
26
+ - Telemetry `>`, `~`, `+` glyphs removed (`TPS 60.6 tok/s · TTFT 2.5s · 8.3s · ↑395 · ↓505 · $0.16` not `> TPS … · ~ TTFT … · +8.3s`)
27
+ - Telemetry bottom border compacted (`TPS 60.6 tok/s · TTFT 2.5s · 8.3s · ↑395 · ↓505 · $0.16` not `> TPS 60.6 tok/s | ~ TTFT 2.5s | + 8.3s | ↑ 395 | ↓ 505 | $0.16`; `TPS`/`TTFT` labels preserved, no space after `↑`/`↓`, joiner `·` not ` | `, stall `!2·3.3s` not `! stall 2x / 3.3s`)
28
+ - Footer cache omitted to the right of input/output (`↑ 395 | ↓ 505 | $0.16` not `| c 85.3%`; cache remains only in top context bar ` # [...] | c 0.0%`)
29
+ - Nerd mode cost now `$0.00` not ` $0.00` (always single `$` in both footer `renderFooter` and telemetry `formatTurnTelemetry`, was `glyph === "$" ? `$ / ` : `glyph $` -> ` $`)
30
+ - Cache session now always shown with zero value (`c 0.0%` in footer `↑ | ↓ | $ | c` and ` | c 0.0%` in top context bar) instead of hidden when no cache tokens
31
+ - Footer cost now `toFixed(2)` (`$0.16` not `$0.160`) and moved directly after input/output (`↑ 395 | ↓ 505 | $0.16 | c 85.3%` not `c | $`; `theme.fg("dim","|")` pipe)
32
+ - Top border model label no longer shows context window (`provider/model · thinking` not `· 1.0M`; `buildLabel` ignores `contextWindow`)
33
+ - Context bar now appends cache hit rate with pipe (` # [#####-------] 39.6% · 416k/1.0M | c 85.3%` via `formatContextBar(..., cacheHitRate)` from `getUsageTotals`) and model/context are pipe-separated (`model | context` via dim `|`)
34
+ - Context bar moved from left bottom border to top border right of model info (`── model · thinking · 1.0M # [#####-------] 39.6% · 416k/1.0M ── T1 · 8s` via `embedTopWithLeftAndRight`, `TrackingEditor.setTopContextText`)
35
+
36
+ ## [0.2.0] - 2026-08-21
37
+
38
+ ### Added
39
+
40
+ - Real-time telemetry via `TurnTelemetryTracker.peekLive()` and 1 s live tick — bottom border `> TPS 60.6 tok/s | ~ TTFT 2.5s | + 8.3s | ↑ 395 | ↓ 505 | $0.16` refreshes on every `agent/turn/message/tool` event during streaming
41
+ - Top-border run activity `T1 · 8s · 2 tools · 1 failed` via `RunActivityTracker` (turn · duration · tool calls · failed) adapted from `pi-atelier/src/run-activity.ts`
42
+ - Context bar moved to left bottom border `── # [#####-------] 39.6% · 416k/1.0M ──` (`formatContextBar` helper, `barWidth 10`, theme-aware, respects `footerSegments.context`, live-updates)
43
+ - Architecture deepening: split `utils.ts` into 5 focused modules (`path-format`, `color-policy`, `format`, `layout`, `tip-policy`), deepened `ConfigStore`, `Footer`, `TrackingEditor` (`BorderRenderer`/`CursorPolicy`), `SessionKernel` (candidates 1–5)
44
+
45
+ ### Changed
46
+
47
+ - Footer no longer renders context (moved to border); right block is now just stats (`↑`/`↓`/`$`)
48
+ - Context window `formatContextWindow` now `1.0M` (was `1m` lowercase no decimal) to match `fmtTokens` (`362k/1.0M`)
49
+
50
+ ### Fixed
51
+
52
+ - Streaming `TPS — | ↓ 0` until `turn_end` — now estimates live tokens (`~4 chars/token`) and shows live `TPS`/`↓` during `message_update`
53
+ - Cost duplicate `$ $0.16/M` → `$0.16` (single `$` when glyph is `$`, nerd keeps ` $0.16`) and hid `per M` suffix as common sense
54
+ - Cost duplicate in footer `$ $0.000` → `$0.000`
55
+ - `formatContextWindow` `1m` → `1.0M` (capital `M`, one decimal) for `0.9k/1.0M` consistency
56
+
7
57
  ## [0.1.2] - 2026-08-20
8
58
 
9
59
  ### Fixed
60
+
10
61
  - `npm:pi-editor-footer` not being discovered when installed via `pi install npm:pi-editor-footer` — added `pi.extensions` (`dist/index.js`) and `keywords` so pi loads the theme from `~/.pi/agent/npm/node_modules`
11
62
 
12
63
  ## [0.1.1] - 2026-08-20
13
64
 
14
-
15
65
  ### Fixed
66
+
16
67
  - `npm:pi-editor-footer` now works when installed via `pi install npm:pi-editor-footer` — added `main` (`dist/index.js`), `files`, and `build` (`tsc --project tsconfig.build.json`) so pi can discover the extension from `~/.pi/agent/npm/node_modules`
17
68
 
18
69
  ## [0.1.0] - 2026-08-20
19
70
 
20
-
21
71
  ### Added
22
72
 
23
73
  - Full TUI theme `pi-editor-footer` rebuilt on `TrackingEditor`: project-aware footer (`cwd` · `git` • `runtime` left, `tokens` next to `context` right), model-info border glow top, live theme respect, detail window preserved
@@ -0,0 +1,194 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { buildLabel, } from "./model-info.js";
3
+ function stripAnsi(s) {
4
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
5
+ }
6
+ function isBorderLine(line) {
7
+ const plain = stripAnsi(line);
8
+ return /^─+$/.test(plain) || /^─── [↑↓] \d+ more/.test(plain);
9
+ }
10
+ /**
11
+ * BorderRenderer — deep module owning top glow + bottom border embedding.
12
+ * Deduplicates stripAnsi/isBorder logic previously spread between model-info.ts
13
+ * and tracking-editor.ts. Behind a single seam, tested via render().
14
+ */
15
+ export class BorderRenderer {
16
+ getLiveTheme;
17
+ getModelInfo;
18
+ constructor(getLiveTheme, getModelInfo) {
19
+ this.getLiveTheme = getLiveTheme;
20
+ this.getModelInfo = getModelInfo;
21
+ }
22
+ renderWithBorders(lines, width, opts) {
23
+ let out = [...lines];
24
+ if (out.length === 0)
25
+ return out;
26
+ // Top: model info left, context bar right of it, run-activity far right
27
+ const hasTop = opts.glowEnabled || opts.topRightText || opts.topContextText;
28
+ if (hasTop) {
29
+ const theme = this.getLiveTheme();
30
+ const info = this.getModelInfo();
31
+ const glow = (s) => {
32
+ try {
33
+ const maybeGlow = theme.getThinkingBorderColor;
34
+ if (typeof maybeGlow === "function")
35
+ return maybeGlow.call(theme, info.level)(s);
36
+ }
37
+ catch {
38
+ // ignore
39
+ }
40
+ return s;
41
+ };
42
+ if (opts.glowEnabled) {
43
+ // Build left label; if context bar present, append it to the right of model info with pipe separator
44
+ let leftLabel = buildLabel(theme, info.provider, info.modelId, info.level, info.contextWindow);
45
+ if (opts.topContextText) {
46
+ leftLabel = `${leftLabel} ${theme.fg("dim", "|")} ${opts.topContextText}`;
47
+ }
48
+ // Embed left (model+context) and optional right (run activity) in one pass to avoid double-truncate
49
+ if (opts.topRightText) {
50
+ out = embedTopWithLeftAndRight(out, width, leftLabel, opts.topRightText, glow);
51
+ }
52
+ else {
53
+ // Only left (model+context), no right — use applyModelInfo replacement but with combined left
54
+ // Reuse embedTopWithLeftAndRight with empty right
55
+ out = embedTopWithLeftAndRight(out, width, leftLabel, "", glow);
56
+ }
57
+ }
58
+ else if (opts.topContextText || opts.topRightText) {
59
+ // No glow, but still need top borders for context/run activity
60
+ const left = opts.topContextText ?? "";
61
+ const right = opts.topRightText ?? "";
62
+ if (left && right) {
63
+ out = embedTopWithLeftAndRight(out, width, left, right, glow);
64
+ }
65
+ else if (left) {
66
+ out = embedTopRightBorder(out, width, left, glow);
67
+ }
68
+ else if (right) {
69
+ out = embedTopRightBorder(out, width, right, glow);
70
+ }
71
+ }
72
+ }
73
+ // Bottom embedding (telemetry left/right)
74
+ if (opts.telemetryText || opts.bottomLeftText) {
75
+ const theme = this.getLiveTheme();
76
+ const glow = (s) => {
77
+ try {
78
+ const maybeGlow = theme.getThinkingBorderColor;
79
+ if (typeof maybeGlow === "function")
80
+ return maybeGlow.call(theme, this.getModelInfo().level)(s);
81
+ }
82
+ catch {
83
+ // ignore
84
+ }
85
+ return s;
86
+ };
87
+ out = embedBottomBorder(out, width, opts.bottomLeftText ?? "", opts.telemetryText ?? "", glow);
88
+ }
89
+ return out;
90
+ }
91
+ }
92
+ function embedTopWithLeftAndRight(lines, width, leftText, rightText, getGlow) {
93
+ if (lines.length === 0)
94
+ return lines;
95
+ const top = lines[0] ?? "";
96
+ const plainTop = stripAnsi(top);
97
+ if (/^─── [↑↓] \d+ more/.test(plainTop))
98
+ return lines;
99
+ const leftW = leftText ? visibleWidth(leftText) : 0;
100
+ const rightW = rightText ? visibleWidth(rightText) : 0;
101
+ const maxLeft = rightText ? Math.max(0, width - rightW - 6) : width - 3;
102
+ const maxRight = leftText ? Math.max(0, width - leftW - 6) : width - 3;
103
+ let displayLeft = leftText;
104
+ let displayRight = rightText;
105
+ if (leftW > maxLeft)
106
+ displayLeft = truncateToWidth(leftText, maxLeft, "");
107
+ if (rightW > maxRight)
108
+ displayRight = truncateToWidth(rightText, maxRight, "");
109
+ const leftSegment = displayLeft ? `${getGlow("─")} ${displayLeft} ` : getGlow("─");
110
+ const rightSegment = displayRight ? ` ${displayRight} ${getGlow("─")}` : getGlow("─");
111
+ const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
112
+ const middleWidth = Math.max(0, width - used);
113
+ const middle = getGlow("─".repeat(middleWidth));
114
+ const embedded = `${leftSegment}${middle}${rightSegment}`;
115
+ const result = [...lines];
116
+ result[0] = truncateToWidth(embedded, width, "");
117
+ if (visibleWidth(embedded) !== width)
118
+ result[0] = embedded;
119
+ return result;
120
+ }
121
+ function embedBottomBorder(lines, width, leftText, rightText, getGlow) {
122
+ if ((!leftText && !rightText) || lines.length === 0)
123
+ return lines;
124
+ let bottomIdx = -1;
125
+ for (let i = lines.length - 1; i >= 0; i--) {
126
+ if (isBorderLine(lines[i] ?? "")) {
127
+ bottomIdx = i;
128
+ break;
129
+ }
130
+ }
131
+ if (bottomIdx === -1)
132
+ return lines;
133
+ const leftW = leftText ? visibleWidth(leftText) : 0;
134
+ const rightW = rightText ? visibleWidth(rightText) : 0;
135
+ const maxLeft = rightText ? Math.max(0, width - rightW - 6) : width - 3;
136
+ const maxRight = leftText ? Math.max(0, width - leftW - 6) : width - 3;
137
+ let displayLeft = leftText;
138
+ let displayRight = rightText;
139
+ if (leftW > maxLeft)
140
+ displayLeft = truncateToWidth(leftText, maxLeft, "");
141
+ if (rightW > maxRight)
142
+ displayRight = truncateToWidth(rightText, maxRight, "");
143
+ const leftSegment = displayLeft
144
+ ? `${getGlow("─")} ${displayLeft} `
145
+ : getGlow("─");
146
+ const rightSegment = displayRight
147
+ ? ` ${displayRight} ${getGlow("─")}`
148
+ : getGlow("─");
149
+ const used = visibleWidth(leftSegment) + visibleWidth(rightSegment);
150
+ const middleWidth = Math.max(0, width - used);
151
+ const middle = getGlow("─".repeat(middleWidth));
152
+ const embedded = `${leftSegment}${middle}${rightSegment}`;
153
+ const result = [...lines];
154
+ result[bottomIdx] = truncateToWidth(embedded, width, "");
155
+ if (visibleWidth(embedded) !== width)
156
+ result[bottomIdx] = embedded;
157
+ return result;
158
+ }
159
+ function embedTopRightBorder(lines, width, rightText, getGlow) {
160
+ if (!rightText || lines.length === 0)
161
+ return lines;
162
+ // Top border is always lines[0] — unless it's a scroll indicator, treat similarly
163
+ const top = lines[0] ?? "";
164
+ const plainTop = stripAnsi(top);
165
+ // If top is scroll indicator, don't embed right — keep glow recolor only
166
+ if (/^─── [↑↓] \d+ more/.test(plainTop))
167
+ return lines;
168
+ const rightW = visibleWidth(rightText);
169
+ // left label already embedded by applyModelInfo — its visible width is width - middle - right
170
+ // We need to truncate right if too wide, preserving at least 10 chars for left
171
+ const maxRight = Math.max(0, width - 12);
172
+ let displayRight = rightText;
173
+ if (rightW > maxRight)
174
+ displayRight = truncateToWidth(rightText, maxRight, "");
175
+ const displayW = visibleWidth(displayRight);
176
+ // Rebuild top: keep existing left-embedded line, replace its right tail
177
+ const existing = lines[0] ?? "";
178
+ // Strip then rebuild: we need to preserve left part and insert right
179
+ // Simple approach: truncate existing to width - displayW - 3, then add " " + displayRight + " " + glow("─")
180
+ const availableForLeft = Math.max(0, width - displayW - 3);
181
+ const leftPart = truncateToWidth(existing, availableForLeft, "");
182
+ // Ensure leftPart ends with glow dash if truncated
183
+ const rightSegment = ` ${displayRight} ${getGlow("─")}`;
184
+ const leftW2 = visibleWidth(leftPart);
185
+ const rightW2 = visibleWidth(rightSegment);
186
+ const middleWidth = Math.max(0, width - leftW2 - rightW2);
187
+ const middle = getGlow("─".repeat(middleWidth));
188
+ const embedded = `${leftPart}${middle}${rightSegment}`;
189
+ const result = [...lines];
190
+ result[0] = truncateToWidth(embedded, width, "");
191
+ if (visibleWidth(embedded) !== width)
192
+ result[0] = embedded;
193
+ return result;
194
+ }
@@ -0,0 +1,53 @@
1
+ export function stressColor(value, warn = 70, danger = 90) {
2
+ if (value >= danger)
3
+ return "error";
4
+ if (value >= warn)
5
+ return "warning";
6
+ return "accent";
7
+ }
8
+ export function cacheHitColor(value) {
9
+ if (value < 30)
10
+ return "error";
11
+ if (value < 70)
12
+ return "warning";
13
+ return "success";
14
+ }
15
+ export function providerColor(provider) {
16
+ switch (provider.toLowerCase()) {
17
+ case "anthropic":
18
+ return "accent";
19
+ case "openai":
20
+ case "openai-codex":
21
+ return "success";
22
+ case "google":
23
+ case "google-vertex":
24
+ return "warning";
25
+ case "amazon-bedrock":
26
+ return "thinkingHigh";
27
+ case "github-copilot":
28
+ return "mdLink";
29
+ case "deepseek":
30
+ return "thinkingLow";
31
+ case "xai":
32
+ case "groq":
33
+ return "error";
34
+ default:
35
+ return "muted";
36
+ }
37
+ }
38
+ export function effortColor(level) {
39
+ switch (level) {
40
+ case "minimal":
41
+ return "thinkingMinimal";
42
+ case "low":
43
+ return "thinkingLow";
44
+ case "medium":
45
+ return "thinkingMedium";
46
+ case "high":
47
+ return "thinkingHigh";
48
+ case "xhigh":
49
+ return "thinkingXhigh";
50
+ default:
51
+ return "thinkingMedium";
52
+ }
53
+ }
package/dist/config.js CHANGED
@@ -9,6 +9,7 @@ export const DEFAULT_CONFIG = {
9
9
  icons: {
10
10
  mode: "auto",
11
11
  },
12
+ contextIconBar: false,
12
13
  footerSegments: {
13
14
  cwd: true,
14
15
  sessionName: false,
@@ -30,6 +31,12 @@ export const DEFAULT_CONFIG = {
30
31
  stalls: true,
31
32
  cost: true,
32
33
  },
34
+ timeline: {
35
+ enabled: true,
36
+ wallTime: true,
37
+ tokens: true,
38
+ cost: true,
39
+ },
33
40
  };
34
41
  export function getConfigPath() {
35
42
  const home = homedir();
@@ -63,69 +70,137 @@ function deepMerge(base, override) {
63
70
  }
64
71
  return result;
65
72
  }
73
+ function isBoolean(v) {
74
+ return typeof v === "boolean";
75
+ }
66
76
  function validate(config) {
67
- // workspaceDisplay
68
77
  if (config.workspaceDisplay !== "path" &&
69
78
  config.workspaceDisplay !== "name") {
70
79
  config.workspaceDisplay = DEFAULT_CONFIG.workspaceDisplay;
71
80
  }
72
- // cursorStyle
73
81
  if (config.cursorStyle !== "block" &&
74
82
  config.cursorStyle !== "bar" &&
75
83
  config.cursorStyle !== "underline") {
76
84
  config.cursorStyle = DEFAULT_CONFIG.cursorStyle;
77
85
  }
78
- // icons.mode
79
86
  if (config.icons.mode !== "auto" &&
80
87
  config.icons.mode !== "nerd" &&
81
88
  config.icons.mode !== "ascii") {
82
89
  config.icons.mode = DEFAULT_CONFIG.icons.mode;
83
90
  }
91
+ if (!isBoolean(config.enabled)) {
92
+ config.enabled = DEFAULT_CONFIG.enabled;
93
+ }
94
+ if (!isBoolean(config.contextIconBar)) {
95
+ config.contextIconBar = DEFAULT_CONFIG.contextIconBar;
96
+ }
97
+ const fs = config.footerSegments;
98
+ const dfs = DEFAULT_CONFIG.footerSegments;
99
+ for (const k of Object.keys(dfs)) {
100
+ if (!isBoolean(fs[k]))
101
+ fs[k] = dfs[k];
102
+ }
103
+ const t = config.telemetry;
104
+ const dt = DEFAULT_CONFIG.telemetry;
105
+ for (const k of Object.keys(dt)) {
106
+ if (!isBoolean(t[k]))
107
+ t[k] = dt[k];
108
+ }
109
+ const tl = config.timeline;
110
+ const dtl = DEFAULT_CONFIG.timeline;
111
+ if (!tl || typeof tl !== "object") {
112
+ config.timeline = structuredClone(DEFAULT_CONFIG.timeline);
113
+ }
114
+ else {
115
+ for (const k of Object.keys(dtl)) {
116
+ if (!isBoolean(tl[k]))
117
+ tl[k] = dtl[k];
118
+ }
119
+ }
84
120
  return config;
85
121
  }
86
- export function ensureConfigExists() {
87
- const path = getConfigPath();
88
- if (existsSync(path))
89
- return;
90
- try {
91
- const dir = join(path, "..");
92
- if (!existsSync(dir))
93
- mkdirSync(dir, { recursive: true });
94
- writeFileSync(path, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
95
- }
96
- catch {
97
- // best-effort
122
+ export class ConfigStore {
123
+ readFile;
124
+ writeFile;
125
+ exists;
126
+ mkdirSyncFn;
127
+ explicitPath;
128
+ listeners = new Set();
129
+ constructor(deps = {}) {
130
+ this.readFile =
131
+ deps.readFile ??
132
+ ((p, enc) => readFileSync(p, enc));
133
+ this.writeFile =
134
+ deps.writeFile ??
135
+ ((p, d, enc) => writeFileSync(p, d, enc));
136
+ this.exists = deps.exists ?? existsSync;
137
+ this.mkdirSyncFn = deps.mkdirSync ?? mkdirSync;
138
+ this.explicitPath = deps.path;
98
139
  }
99
- }
100
- export function loadConfig() {
101
- const path = getConfigPath();
102
- if (!existsSync(path)) {
103
- ensureConfigExists();
104
- return structuredClone(DEFAULT_CONFIG);
140
+ getPath() {
141
+ return this.explicitPath ?? getConfigPath();
105
142
  }
106
- try {
107
- const raw = readFileSync(path, "utf8");
108
- const parsed = JSON.parse(raw);
109
- const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
110
- return validate(merged);
143
+ get() {
144
+ const p = this.getPath();
145
+ if (!this.exists(p)) {
146
+ try {
147
+ const dir = join(p, "..");
148
+ if (!this.exists(dir))
149
+ this.mkdirSyncFn(dir, { recursive: true });
150
+ this.writeFile(p, JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n", "utf8");
151
+ }
152
+ catch {
153
+ // best-effort
154
+ }
155
+ return structuredClone(DEFAULT_CONFIG);
156
+ }
157
+ try {
158
+ const raw = this.readFile(p, "utf8");
159
+ const parsed = JSON.parse(raw);
160
+ const merged = deepMerge(structuredClone(DEFAULT_CONFIG), parsed);
161
+ return validate(merged);
162
+ }
163
+ catch (err) {
164
+ console.warn(`[pi-skill-desc] config parse error (${p}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
165
+ return structuredClone(DEFAULT_CONFIG);
166
+ }
111
167
  }
112
- catch (err) {
113
- console.warn(`[pi-skill-desc] config parse error (${path}): ${err instanceof Error ? err.message : String(err)} — using defaults`);
114
- return structuredClone(DEFAULT_CONFIG);
168
+ patch(patch) {
169
+ const prev = this.get();
170
+ const merged = validate(deepMerge(prev, patch));
171
+ const p = this.getPath();
172
+ try {
173
+ const dir = join(p, "..");
174
+ if (!this.exists(dir))
175
+ this.mkdirSyncFn(dir, { recursive: true });
176
+ this.writeFile(p, JSON.stringify(merged, null, 2) + "\n", "utf8");
177
+ }
178
+ catch {
179
+ // best-effort
180
+ }
181
+ for (const fn of this.listeners) {
182
+ try {
183
+ fn(merged, prev);
184
+ }
185
+ catch {
186
+ // subscriber error should not break store
187
+ }
188
+ }
189
+ return merged;
190
+ }
191
+ subscribe(fn) {
192
+ this.listeners.add(fn);
193
+ return () => this.listeners.delete(fn);
115
194
  }
116
195
  }
196
+ // Default singleton for backward-compat free functions
197
+ const defaultStore = new ConfigStore();
198
+ export function ensureConfigExists() {
199
+ void defaultStore.get();
200
+ }
201
+ export function loadConfig() {
202
+ return defaultStore.get();
203
+ }
117
204
  export function saveConfig(patch) {
118
- const current = loadConfig();
119
- const merged = validate(deepMerge(current, patch));
120
- const path = getConfigPath();
121
- try {
122
- const dir = join(path, "..");
123
- if (!existsSync(dir))
124
- mkdirSync(dir, { recursive: true });
125
- writeFileSync(path, JSON.stringify(merged, null, 2) + "\n", "utf8");
126
- }
127
- catch {
128
- // best-effort
129
- }
130
- return merged;
205
+ return defaultStore.patch(patch);
131
206
  }
@@ -0,0 +1,77 @@
1
+ import { CURSOR_MARKER } from "@earendil-works/pi-tui";
2
+ const CURSOR_STYLE_SEQUENCES = {
3
+ bar: "\x1b[6 q",
4
+ underline: "\x1b[4 q",
5
+ };
6
+ const DEFAULT_CURSOR_STYLE_SEQUENCE = "\x1b[0 q";
7
+ function stripAnsi(s) {
8
+ return s.replace(/\x1b\[[0-9;]*m/g, "");
9
+ }
10
+ function removeSoftwareCursor(line, cursorMarker = "") {
11
+ return line.replace(/\x1b\[7m([\s\S]*?)\x1b\[0m/g, (_match, cursor) => {
12
+ const replacement = `${cursorMarker}${cursor}`;
13
+ cursorMarker = "";
14
+ return replacement;
15
+ });
16
+ }
17
+ function configureCursor(tui, cursorStyle) {
18
+ if (cursorStyle === "block")
19
+ return;
20
+ const setShow = tui.setShowHardwareCursor;
21
+ if (typeof setShow === "function")
22
+ setShow.call(tui, true);
23
+ const seq = CURSOR_STYLE_SEQUENCES[cursorStyle];
24
+ const term = tui
25
+ .terminal;
26
+ if (seq && term && typeof term.write === "function")
27
+ term.write(seq);
28
+ }
29
+ /**
30
+ * CursorPolicy — owns cursor style + hardware cursor juggling behind one seam.
31
+ * Previously tangled inside TrackingEditor; now a deep collaborator injected
32
+ * into the editor. Interface is the test surface.
33
+ */
34
+ export class CursorPolicy {
35
+ tui;
36
+ style = "block";
37
+ previewHardwareCursor = false;
38
+ constructor(tui) {
39
+ this.tui = tui;
40
+ }
41
+ getStyle() {
42
+ return this.style;
43
+ }
44
+ setStyle(style) {
45
+ const changed = style !== this.style;
46
+ this.previewHardwareCursor = style !== "block";
47
+ this.style = style;
48
+ if (changed) {
49
+ const tuiAny = this.tui;
50
+ if (style === "block") {
51
+ if (tuiAny.terminal)
52
+ tuiAny.terminal.write(DEFAULT_CURSOR_STYLE_SEQUENCE);
53
+ if (typeof tuiAny.setShowHardwareCursor === "function")
54
+ tuiAny.setShowHardwareCursor(false);
55
+ }
56
+ else {
57
+ configureCursor(this.tui, style);
58
+ }
59
+ }
60
+ }
61
+ // Called by TrackingEditor.renderBase — removes software cursor and injects hardware marker when needed
62
+ mapLines(lines, isFocused) {
63
+ if (this.style === "block")
64
+ return lines;
65
+ let cursorMarker = this.previewHardwareCursor && !isFocused ? CURSOR_MARKER : "";
66
+ if (isFocused)
67
+ this.previewHardwareCursor = false;
68
+ return lines.map((line) => {
69
+ const rendered = removeSoftwareCursor(line, cursorMarker);
70
+ if (rendered !== line)
71
+ cursorMarker = "";
72
+ return rendered;
73
+ });
74
+ }
75
+ }
76
+ export { configureCursor, removeSoftwareCursor };
77
+ export const __testing = { stripAnsi };
@@ -0,0 +1,70 @@
1
+ import { readGitStatus } from "./git.js";
2
+ import { readRuntimeInfo } from "./runtime.js";
3
+ import { createInitialState } from "./state.js";
4
+ /**
5
+ * FooterController — deep module owning FooterState + git/runtime polling behind one seam.
6
+ * Previously FooterState lived in index.ts and callers had to orchestrate readGitStatus/readRuntimeInfo
7
+ * and merge into state. Now all that hides behind Controller.
8
+ *
9
+ * Interface is the test surface: new FooterController({ getCwd }) → { getState, refresh, setWorking, setDone }
10
+ * The rendering adapter (setFooter vs setWidget) is also hidden inside install path (two adapters → real seam).
11
+ */
12
+ export class FooterController {
13
+ state = createInitialState();
14
+ getCwd;
15
+ constructor(opts) {
16
+ this.getCwd = opts.getCwd;
17
+ if (opts.initialState)
18
+ this.state = opts.initialState;
19
+ }
20
+ getState() {
21
+ return this.state;
22
+ }
23
+ setWorking(since) {
24
+ this.state = {
25
+ ...this.state,
26
+ workingSince: since,
27
+ lastDoneIn: since === undefined ? this.state.lastDoneIn : undefined,
28
+ };
29
+ }
30
+ setDone(doneIn) {
31
+ this.state = { ...this.state, lastDoneIn: doneIn, workingSince: undefined };
32
+ }
33
+ async refreshGit() {
34
+ try {
35
+ const cwd = this.getCwd();
36
+ const git = await readGitStatus(cwd);
37
+ this.state = { ...this.state, git };
38
+ }
39
+ catch {
40
+ // best-effort
41
+ }
42
+ }
43
+ async refreshRuntime() {
44
+ try {
45
+ const cwd = this.getCwd();
46
+ const runtime = await readRuntimeInfo(cwd);
47
+ this.state = { ...this.state, runtime };
48
+ }
49
+ catch {
50
+ // best-effort
51
+ }
52
+ }
53
+ async refreshAll() {
54
+ await this.refreshGit();
55
+ await this.refreshRuntime();
56
+ }
57
+ }
58
+ /**
59
+ * createFooter — deep public factory.
60
+ * Owns polling and state; callers supply only getCwd/getConfig/getContextUsage.
61
+ * Keeps renderFooter pure as internal seam.
62
+ */
63
+ export function createFooter(opts) {
64
+ const ctrl = new FooterController({ getCwd: opts.getCwd });
65
+ return {
66
+ controller: ctrl,
67
+ getState: () => ctrl.getState(),
68
+ refresh: () => ctrl.refreshAll(),
69
+ };
70
+ }