@pi-unipi/footer 3.0.0-alpha.2 → 3.0.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -37,7 +37,7 @@ Footer subscribes to events from every Unipi package:
37
37
  | Group | Events | Segments |
38
38
  |-------|--------|----------|
39
39
  | core | Pi SDK | model, thinking, path, git, context_pct, cost, tokens, session |
40
- | compactor | Pi session data + `COMPACTOR_COMPACTED` | session_events, compactions, tokens_saved, compression_ratio |
40
+ | compactor | Pi session data | compactions — `cmp 4× 39k→13k · 3m` (count, tokens before → after, time since the last; hidden until the first) |
41
41
  | memory | `MEMORY_STORED`/`DELETED`/`CONSOLIDATED` | project_count, total_count, consolidations |
42
42
  | mcp | `MCP_SERVER_STARTED`/`STOPPED`/`ERROR` | servers_total, servers_active, tools_total |
43
43
  | ralph | `RALPH_LOOP_START`/`END`/`ITERATION_DONE` | active_loops, total_iterations, loop_status |
@@ -90,9 +90,7 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
90
90
  "compactor": {
91
91
  "show": true,
92
92
  "segments": {
93
- "session_events": true,
94
- "compactions": true,
95
- "tokens_saved": true
93
+ "compactions": true
96
94
  }
97
95
  }
98
96
  }
@@ -137,11 +135,11 @@ When `iconStyle` is not set, footer auto-detects Nerd Font support and defaults
137
135
 
138
136
  ```
139
137
  Wide terminal (>120 cols):
140
- model | thinking | path | git | context | cost | compactions | tokens_saved | project_count
138
+ model | thinking | path | git | context | compactions | cost | project_count
141
139
 
142
140
  Narrow terminal (<120 cols):
143
141
  Row 1: model | thinking | path | git | context | cost
144
- Row 2: compactions | tokens_saved | project_count | ralph | workflow
142
+ Row 2: compactions | project_count | ralph | workflow
145
143
  ```
146
144
 
147
145
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "3.0.0-alpha.2",
3
+ "version": "3.0.0-alpha.20",
4
4
  "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -32,8 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "3.0.0-alpha.2",
36
- "@pi-unipi/background-tasks": "3.0.0-alpha.2"
35
+ "@pi-unipi/core": "3.0.0-alpha.20",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.20"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.87.1",
@@ -5,12 +5,14 @@
5
5
  * subclass CustomEditor, use super.render() ONLY to obtain editor content
6
6
  * lines, then compose our own rounded frame. Layout per the v3 design:
7
7
  *
8
- * ╭─UNIPI │ feat/footer-default-v2 │ ─────────────────────╮
9
- * │ Type your prompt here... │
8
+ * ╭─UNIPI │ feat/footer-default-v2 ─────────────────────╮
9
+ * Type your prompt here...
10
10
  * ╰─ unipi ────────── 42%/1.0M │ Claude Opus 4.5 │ thinking:high ╯
11
11
  *
12
12
  * Top border carries the UNIPI brand + git branch. Bottom border right side
13
13
  * carries workspace · context%/window · model · thinking level; left filler.
14
+ * The body is open-sided: no vertical rails — the rounded corners are the
15
+ * only frame marks (content keeps its column via a one-space indent).
14
16
  * All keybindings/autocomplete/history/paste behavior inherited.
15
17
  */
16
18
 
@@ -19,6 +21,7 @@ import type { EditorOptions, EditorTheme, TUI } from "@earendil-works/pi-tui";
19
21
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
20
22
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
23
  import { getIcon, getResolvedIconStyle } from "./rendering/icons.js";
24
+ import { kanboardGlanceLabel } from "@pi-unipi/core";
22
25
  import { lolcatRainbow, paintLolcatGradient } from "./rendering/lolcat.js";
23
26
 
24
27
  /** Live status injected by the footer extension. */
@@ -41,12 +44,13 @@ export interface GlanceStatus {
41
44
  modelName: string;
42
45
  /** Current thinking level (already "off"-filtered by the provider). */
43
46
  thinkingLevel: string | null;
44
- /**
45
- * Active Fusion pair, when selected. When set, the model slot renders
46
- * `Fusion · <lead> <effort> ◆ <sidekick>` — lead lit, sidekick muted —
47
- * and the plain thinking slot is suppressed (efforts are inline).
47
+ /** Active Fusion pair, when selected. When set, the model slot renders
48
+ * `Fusion · <lead> <effort> ◆ <sidekick>` — lead lit, sidekick muted —
49
+ * and the plain thinking slot is suppressed (efforts are inline).
48
50
  */
49
51
  fusion: { leadName: string; leadEffort: string; sidekickName: string; sidekickEffort: string; savedUsd?: number; busy?: boolean; leadToolCalls?: number; sidekickToolCalls?: number } | null;
52
+ /** Kanboard claims/autowork snapshot (null → no `▣` segment). */
53
+ kanboard: { claims: string[]; autowork: boolean } | null;
50
54
  }
51
55
 
52
56
  const BORDER = {
@@ -196,17 +200,21 @@ export function composeGlanceTitles(
196
200
  branch: string | null,
197
201
  workspace: string,
198
202
  lhMode: string | null = null,
203
+ kanboard: { claims: string[]; autowork: boolean } | null = null,
199
204
  ): { titleParts: string[]; leftTitle: string } {
205
+ const kanboardLabel = kanboardGlanceLabel(kanboard);
200
206
  const titleParts: string[] = [];
201
207
  if (getResolvedIconStyle() === "text") {
202
208
  titleParts.push(brand);
203
209
  if (lhMode) titleParts.push(`mode:${lhMode}`);
210
+ if (kanboardLabel) titleParts.push(kanboardLabel.replace(/^▣ /, "kanboard:"));
204
211
  if (branch) titleParts.push(`branch:${branch}`);
205
212
  return { titleParts, leftTitle: ` workspace:${workspace} ` };
206
213
  }
207
214
  const brandIcon = getIcon("model");
208
215
  titleParts.push(`${brandIcon ? brandIcon + " " : ""}${brand}`);
209
216
  if (lhMode) titleParts.push(lhMode);
217
+ if (kanboardLabel) titleParts.push(kanboardLabel);
210
218
  if (branch) {
211
219
  const gitIcon = getIcon("git");
212
220
  titleParts.push(`${gitIcon ? gitIcon + " " : ""}${branch}`);
@@ -276,7 +284,7 @@ export class GlanceEditor extends CustomEditor {
276
284
  // Brand rendered as an animated lolcat gradient (phase from wall time;
277
285
  // the footer's 1s refresh timer re-renders, so it shimmers each tick).
278
286
  const brand = lolcatRainbow("UNIPI", Date.now() / 1000);
279
- const { titleParts, leftTitle } = composeGlanceTitles(brand, st.branch, st.workspace, st.lhMode);
287
+ const { titleParts, leftTitle } = composeGlanceTitles(brand, st.branch, st.workspace, st.lhMode, st.kanboard);
280
288
  const title = titleParts.join(SEP);
281
289
  const leadRule = `${BORDER.horizontal} `;
282
290
  const titleText = ` ${title}${SEP}`;
@@ -296,10 +304,10 @@ export class GlanceEditor extends CustomEditor {
296
304
  topCluster +
297
305
  border(BORDER.topRight);
298
306
 
299
- // ── Body rows: │ content │ ──
300
- const bodyRows = contentLines.map(row =>
301
- border(BORDER.vertical) + padLine(row, inner) + border(BORDER.vertical),
302
- );
307
+ // ── Body rows: open sides — the corner curves are the only frame marks.
308
+ // One-space indent keeps content in the column it had between the old
309
+ // rails; no trailing padding (rows may end short of the frame width).
310
+ const bodyRows = contentLines.map(row => " " + truncateToWidth(row, inner, ""));
303
311
 
304
312
  // ── Bottom frame: ╰─ unipi ─────── [RIGHT CLUSTER] ─╯ ──
305
313
  // Right cluster: workspace · pct%/window │ model │ thinking:level
package/src/help.ts CHANGED
@@ -75,7 +75,7 @@ function buildHelpLines(
75
75
  export function getGroupForSegment(segId: string): string {
76
76
  const coreIds = ["model", "api_state", "tool_count", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time", "tps", "clock", "duration", "thinking_level", "uni", "directory"];
77
77
  if (coreIds.includes(segId)) return "core";
78
- const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "sandbox_runs", "search_queries"];
78
+ const compactorIds = ["compactions"];
79
79
  if (compactorIds.includes(segId)) return "compactor";
80
80
  if (["project_count", "total_count", "consolidations"].includes(segId)) return "memory";
81
81
  if (["servers_total", "servers_active", "tools_total", "servers_failed"].includes(segId)) return "mcp";
package/src/index.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import type { ExtensionAPI, Theme, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
- import { UNIPI_EVENTS, emitEvent, UNIPI_PREFIX, FOOTER_COMMANDS, getSharedFusionStatus, getSharedLongHorizonMode, getSharedPlanPermissionStatus } from "@pi-unipi/core";
10
+ import { UNIPI_EVENTS, emitEvent, UNIPI_PREFIX, FOOTER_COMMANDS, getSharedFusionStatus, getSharedKanboardStatus, getSharedLongHorizonMode, getSharedPlanPermissionStatus } from "@pi-unipi/core";
11
11
  import { FooterRegistry, getFooterRegistry } from "./registry/index.js";
12
12
  import { FooterRenderer } from "./rendering/renderer.js";
13
13
  import { subscribeToEvents } from "./events.js";
@@ -18,7 +18,7 @@ import { GlanceEditor } from "./glance-editor.js";
18
18
 
19
19
  // Import segment groups
20
20
  import { CORE_SEGMENTS } from "./segments/core.js";
21
- import { COMPACTOR_SEGMENTS } from "./segments/compactor.js";
21
+ import { COMPACTOR_SEGMENTS, compactionSummary } from "./segments/compactor.js";
22
22
  import { MEMORY_SEGMENTS } from "./segments/memory.js";
23
23
  import { MCP_SEGMENTS } from "./segments/mcp.js";
24
24
  import { RALPH_SEGMENTS } from "./segments/ralph.js";
@@ -448,6 +448,7 @@ function installGlanceEditor(
448
448
  const lhMode = typeof lhModeRaw === "string" && lhModeRaw.length > 0
449
449
  ? MODE_LABELS[lhModeRaw] ?? lhModeRaw
450
450
  : null;
451
+ const kanboard = getSharedKanboardStatus() ?? null;
451
452
  // Plan/permission state rides the same CORE registry data, written by
452
453
  // the workflow module's events (also re-emitted on session_start).
453
454
  const coreData = st.registry.getGroupData("core") as
@@ -470,6 +471,7 @@ function installGlanceEditor(
470
471
  modelName,
471
472
  thinkingLevel: typeof p?.thinkingLevel === "string" ? p.thinkingLevel : null,
472
473
  fusion,
474
+ kanboard,
473
475
  };
474
476
  }),
475
477
  );
@@ -546,14 +548,30 @@ const STRIP_COLOR = {
546
548
  psMid: "\x1b[97m", // white — in between
547
549
  cacheHit: "\x1b[92m", // green — high hit
548
550
  cacheWarn: "\x1b[93m", // amber — lowish hit
551
+ compact: "\x1b[94m", // blue — compactions
549
552
  reset: "\x1b[39m",
550
553
  } as const;
551
554
 
552
555
  const c = (code: string, text: string) => `${code}${text}${STRIP_COLOR.reset}`;
553
556
 
557
+ function fmtTokensShort(n: number): string {
558
+ if (n < 1000) return String(n);
559
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
560
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
561
+ return `${(n / 1000000).toFixed(1)}M`;
562
+ }
563
+
564
+ function agoShort(ms: number): string {
565
+ const sec = Math.max(0, Math.round(ms / 1000));
566
+ if (sec < 60) return "just now";
567
+ if (sec < 3600) return `${Math.round(sec / 60)}m ago`;
568
+ if (sec < 86400) return `${Math.round(sec / 3600)}h ago`;
569
+ return `${Math.round(sec / 86400)}d ago`;
570
+ }
571
+
554
572
  /**
555
573
  * Glance-style centered stats strip under the input:
556
- * n Turn · n Steps | wall · tool wall | avg TTFT · n tok/s | cache n%
574
+ * n Turn · n Steps | wall · tool wall | avg TTFT · n tok/s | n compactions · 39k→13k · 3m ago | cache n%
557
575
  */
558
576
  function renderSessionStrip(piContext: unknown): string | null {
559
577
  const parts: string[] = [];
@@ -588,6 +606,21 @@ function renderSessionStrip(piContext: unknown): string | null {
588
606
  if (seg) parts.push(seg);
589
607
  }
590
608
 
609
+ // Compactions: hidden until the first one.
610
+ const branch = (() => {
611
+ try {
612
+ return ((piContext as any)?.sessionManager?.getBranch?.() ?? []) as unknown[];
613
+ } catch {
614
+ return [];
615
+ }
616
+ })();
617
+ const cmp = compactionSummary(branch);
618
+ if (cmp.count > 0) {
619
+ const sizes = cmp.before > 0 ? ` \u00b7 ${c(STRIP_COLOR.compact, `${fmtTokensShort(cmp.before)}\u2192${fmtTokensShort(cmp.after)}`)}` : "";
620
+ const age = cmp.lastAt != null ? ` \u00b7 ${agoShort(Date.now() - cmp.lastAt)}` : "";
621
+ parts.push(`${c(STRIP_COLOR.compact, String(cmp.count))} compaction${cmp.count === 1 ? "" : "s"}${sizes}${age}`);
622
+ }
623
+
591
624
  const hit = cacheHitPct(piContext);
592
625
  if (hit !== null) {
593
626
  const hitColor = hit >= 70 ? STRIP_COLOR.cacheHit : STRIP_COLOR.cacheWarn;
package/src/presets.ts CHANGED
@@ -18,7 +18,7 @@ const DEFAULT_PRESET: PresetDef = {
18
18
  "uni", "lh_mode", "model", "thinking_level", "directory", "git",
19
19
  ],
20
20
  rightSegments: [
21
- "context_pct", "tokens_total",
21
+ "context_pct", "compactions", "tokens_total",
22
22
  "tps", "cost",
23
23
  "clock", "duration",
24
24
  ],
@@ -33,7 +33,7 @@ const CLASSIC_PRESET: PresetDef = {
33
33
  ],
34
34
  rightSegments: [
35
35
  "tps", "context_pct", "cost",
36
- "compactions", "tokens_saved", "project_count",
36
+ "compactions", "project_count",
37
37
  "current_command", "loop_status", "extension_statuses",
38
38
  "clock", "duration",
39
39
  ],
@@ -76,7 +76,7 @@ const FULL_PRESET: PresetDef = {
76
76
  ],
77
77
  rightSegments: [
78
78
  "tps", "context_pct", "cost", "tokens_total",
79
- "session_events", "compactions", "tokens_saved",
79
+ "compactions",
80
80
  "project_count", "total_count",
81
81
  "servers_total", "servers_active", "tools_total",
82
82
  "active_loops", "loop_status",
@@ -87,7 +87,6 @@ const FULL_PRESET: PresetDef = {
87
87
  secondarySegments: [
88
88
  "hostname",
89
89
  "tokens_in", "tokens_out",
90
- "compression_ratio",
91
90
  "platforms_enabled", "last_sent",
92
91
  "thinking_level",
93
92
  ],
@@ -35,13 +35,8 @@ export interface IconSet {
35
35
  directory: string;
36
36
 
37
37
  // Compactor segments
38
- sessionEvents: string;
39
- compactions: string;
40
- tokensSaved: string;
41
- compressionRatio: string;
42
38
  indexedDocs: string;
43
39
  sandboxRuns: string;
44
- searchQueries: string;
45
40
 
46
41
  // Memory segments
47
42
  projectCount: string;
@@ -106,13 +101,8 @@ export const NERD_ICONS: IconSet = {
106
101
  thinkingLevel: "\uF400", //
107
102
 
108
103
  // Compactor
109
- sessionEvents: "\uEA86", //
110
- compactions: "\u{F0C8F}", // 󰲏
111
- tokensSaved: "\uF155", // (kept — missing from customization)
112
- compressionRatio:"\u{F0C8F}", // 󰲏
113
104
  indexedDocs: "\u{F0219}", // 󰈙
114
105
  sandboxRuns: "\uF233", //
115
- searchQueries: "\uF002", //
116
106
 
117
107
  // Memory
118
108
  projectCount: "\uEE9C", //
@@ -177,13 +167,8 @@ export const EMOJI_ICONS: IconSet = {
177
167
  directory: "📁",
178
168
 
179
169
  // Compactor
180
- sessionEvents: "📈",
181
- compactions: "🗜️",
182
- tokensSaved: "💲",
183
- compressionRatio:"📐",
184
170
  indexedDocs: "📑",
185
171
  sandboxRuns: "▶️",
186
- searchQueries: "🔍",
187
172
 
188
173
  // Memory
189
174
  projectCount: "🧠",
@@ -248,13 +233,8 @@ export const TEXT_ICONS: IconSet = {
248
233
  directory: "DIR",
249
234
 
250
235
  // Compactor
251
- sessionEvents: "EVT",
252
- compactions: "CMP",
253
- tokensSaved: "SVD",
254
- compressionRatio:"RAT",
255
236
  indexedDocs: "IDX",
256
237
  sandboxRuns: "SBX",
257
- searchQueries: "QRY",
258
238
 
259
239
  // Memory
260
240
  projectCount: "MEM",
@@ -1,23 +1,16 @@
1
1
  /**
2
- * @pi-unipi/footer — Compactor segments
2
+ * @pi-unipi/footer — Compactor segment
3
3
  *
4
- * Segment renderers for the compactor group: session_events, compactions,
5
- * tokens_saved, compression_ratio, sandbox_runs, search_queries.
4
+ * One dense, icon-free segment, shown once the session has compacted:
6
5
  *
7
- * Data sourced from piContext.sessionManager (live session data).
8
- * Segments without a reliable data source are hidden (visible: false)
9
- * rather than showing a placeholder like "—".
6
+ * cmp 4× 39k→13k · 3m
7
+ *
8
+ * count, tokens before → after across all compactions, time since the last.
9
+ * Read from the session branch (Pi's compaction entries).
10
10
  */
11
11
 
12
12
  import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
13
- import { applyColor, mutedPlaceholder } from "../rendering/theme.js";
14
- import { getIcon } from "../rendering/icons.js";
15
- import { isSegmentEnabled } from "../config.js";
16
-
17
- function withIcon(segmentId: string, text: string): string {
18
- const icon = getIcon(segmentId);
19
- return icon ? `${icon} ${text}` : text;
20
- }
13
+ import { applyColor } from "../rendering/theme.js";
21
14
 
22
15
  function formatTokens(n: number): string {
23
16
  if (n < 1000) return n.toString();
@@ -26,149 +19,66 @@ function formatTokens(n: number): string {
26
19
  return `${(n / 1000000).toFixed(1)}M`;
27
20
  }
28
21
 
29
- /** Hidden segment — no reliable data source available */
30
- function hidden(): RenderedSegment {
31
- return { content: "", visible: false };
22
+ function ago(ms: number): string {
23
+ const s = Math.max(0, Math.round(ms / 1000));
24
+ if (s < 60) return "now";
25
+ if (s < 3600) return `${Math.round(s / 60)}m`;
26
+ if (s < 86400) return `${Math.round(s / 3600)}h`;
27
+ return `${Math.round(s / 86400)}d`;
32
28
  }
33
29
 
34
- /** Safely extract sessionManager from piContext */
35
- function getSessionManager(ctx: FooterSegmentContext): any {
36
- const piCtx = ctx.piContext as Record<string, unknown> | undefined;
37
- return piCtx?.sessionManager as any | undefined;
30
+ /** Tokens left after a compaction: our recorded estimate, else the summary size. */
31
+ function tokensAfterOf(entry: any, before: number): number {
32
+ const recorded = Number(entry?.details?.tokensAfter ?? 0);
33
+ if (recorded > 0) return Math.min(recorded, before);
34
+ const summaryChars = typeof entry?.summary === "string" ? entry.summary.length : 0;
35
+ return Math.min(before, Math.ceil(summaryChars / 4));
38
36
  }
39
37
 
40
- /** Get all session events from sessionManager branch */
41
- function getSessionEvents(ctx: FooterSegmentContext): any[] {
42
- const sm = getSessionManager(ctx);
43
- if (!sm || typeof sm.getBranch !== "function") return [];
38
+ function branchOf(ctx: FooterSegmentContext): any[] {
39
+ const sm = (ctx.piContext as Record<string, unknown> | undefined)?.sessionManager as any;
44
40
  try {
45
- return sm.getBranch() ?? [];
41
+ return typeof sm?.getBranch === "function" ? (sm.getBranch() ?? []) : [];
46
42
  } catch {
47
43
  return [];
48
44
  }
49
45
  }
50
46
 
51
- function renderSessionEventsSegment(ctx: FooterSegmentContext): RenderedSegment {
52
- const events = getSessionEvents(ctx);
53
- const count = events.length;
54
- if (count === 0) {
55
- if (isSegmentEnabled("compactor", "session_events")) {
56
- return { content: mutedPlaceholder(withIcon("sessionEvents", "0")), visible: true };
57
- }
58
- return hidden();
47
+ export function compactionSummary(branch: readonly any[], now = Date.now()): { count: number; before: number; after: number; lastAt?: number } {
48
+ let count = 0;
49
+ let before = 0;
50
+ let after = 0;
51
+ let lastAt: number | undefined;
52
+ for (const e of branch) {
53
+ if (e?.type !== "compaction") continue;
54
+ count++;
55
+ const b = Number(e.tokensBefore ?? 0);
56
+ before += b;
57
+ after += tokensAfterOf(e, b);
58
+ const at = Date.parse(e.timestamp ?? "");
59
+ if (Number.isFinite(at) && at <= now) lastAt = at;
59
60
  }
60
-
61
- const content = withIcon("sessionEvents", `${count}`);
62
- return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
61
+ return { count, before, after, ...(lastAt != null ? { lastAt } : {}) };
63
62
  }
64
63
 
65
64
  function renderCompactionsSegment(ctx: FooterSegmentContext): RenderedSegment {
66
- // Count compaction entries in the session events
67
- const events = getSessionEvents(ctx);
68
- let compactionCount = 0;
69
- for (const e of events) {
70
- if (!e || typeof e !== "object") continue;
71
- if (e.type === "compaction" || e.type === "compacted") {
72
- compactionCount++;
73
- }
74
- }
75
- if (compactionCount === 0) {
76
- if (isSegmentEnabled("compactor", "compactions")) {
77
- return { content: mutedPlaceholder(withIcon("compactions", "0")), visible: true };
78
- }
79
- return hidden();
80
- }
81
-
82
- const content = withIcon("compactions", `${compactionCount}`);
83
- return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
84
- }
85
-
86
- function renderTokensSavedSegment(ctx: FooterSegmentContext): RenderedSegment {
87
- // Sum tokens saved from compaction entries.
88
- // Pi's CompactionEntry has tokensBefore (total tokens before compaction).
89
- // Compaction keeps ~10-15% of context, so tokens saved ≈ tokensBefore × 0.85.
90
- const events = getSessionEvents(ctx);
91
- let tokensSaved = 0;
92
- let hasCompaction = false;
93
- for (const e of events) {
94
- if (!e || typeof e !== "object") continue;
95
- if (e.type === "compaction") {
96
- hasCompaction = true;
97
- const tokensBefore = Number(e.tokensBefore ?? 0);
98
- // Estimate tokens kept at ~12% (compaction summary + recent messages)
99
- const tokensAfter = Math.round(tokensBefore * 0.12);
100
- tokensSaved += Math.max(0, tokensBefore - tokensAfter);
101
- }
102
- }
103
- if (!hasCompaction || tokensSaved === 0) return hidden();
104
-
105
- const content = withIcon("tokensSaved", formatTokens(tokensSaved));
106
- return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
107
- }
108
-
109
- function renderCompressionRatioSegment(ctx: FooterSegmentContext): RenderedSegment {
110
- // Calculate compression ratio from Pi's CompactionEntry.tokensBefore.
111
- // Compaction keeps ~12% of context, giving ~8:1 compression.
112
- const events = getSessionEvents(ctx);
113
- let totalBefore = 0;
114
- let totalAfter = 0;
115
- for (const e of events) {
116
- if (!e || typeof e !== "object") continue;
117
- if (e.type === "compaction") {
118
- const before = Number(e.tokensBefore ?? 0);
119
- if (before > 0) {
120
- totalBefore += before;
121
- totalAfter += Math.round(before * 0.12);
122
- }
123
- }
124
- }
125
- if (totalBefore === 0 || totalAfter === 0) return hidden();
126
-
127
- const ratio = totalBefore / totalAfter;
128
- const content = withIcon("compressionRatio", `${ratio.toFixed(1)}x`);
129
- return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
130
- }
131
-
132
- function renderSandboxRunsSegment(ctx: FooterSegmentContext): RenderedSegment {
133
- // Count sandbox events from session manager branch
134
- const events = getSessionEvents(ctx);
135
- let sandboxCount = 0;
136
- for (const e of events) {
137
- if (!e || typeof e !== "object") continue;
138
- // Count tool calls that are sandbox/execute tools
139
- const name = String((e as any).name ?? "").toLowerCase();
140
- if (name.includes("sandbox") || name.includes("ctx_execute") || name === "execute") {
141
- sandboxCount++;
142
- }
143
- }
144
- if (sandboxCount === 0) return hidden();
145
-
146
- const content = withIcon("sandboxRuns", `${sandboxCount}`);
147
- return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
148
- }
149
-
150
- function renderSearchQueriesSegment(ctx: FooterSegmentContext): RenderedSegment {
151
- // Count search events from session manager branch
152
- const events = getSessionEvents(ctx);
153
- let searchCount = 0;
154
- for (const e of events) {
155
- if (!e || typeof e !== "object") continue;
156
- const name = String((e as any).name ?? "").toLowerCase();
157
- if (name.includes("search") || name.includes("ctx_search")) {
158
- searchCount++;
159
- }
160
- }
161
- if (searchCount === 0) return hidden();
162
-
163
- const content = withIcon("searchQueries", `${searchCount}`);
164
- return { content: applyColor("compactor", content, ctx.theme, ctx.colors), visible: true };
65
+ const s = compactionSummary(branchOf(ctx));
66
+ if (s.count === 0) return { content: "", visible: false };
67
+ const dim = (t: string) => `\x1b[2m${t}\x1b[22m`;
68
+ const sizes = s.before > 0 ? ` ${formatTokens(s.before)}→${formatTokens(s.after)}` : "";
69
+ const age = s.lastAt != null ? dim(` · ${ago(Date.now() - s.lastAt)}`) : "";
70
+ const content = `${dim("cmp")} ${applyColor("compactor", `${s.count}×${sizes}`, ctx.theme, ctx.colors)}${age}`;
71
+ return { content, visible: true };
165
72
  }
166
73
 
167
74
  export const COMPACTOR_SEGMENTS: FooterSegment[] = [
168
- { id: "session_events", label: "Session Events", shortLabel: "EVT", description: "Number of session events", zone: "center", render: renderSessionEventsSegment, defaultShow: true },
169
- { id: "compactions", label: "Compactions", shortLabel: "CMP", description: "Number of context compactions", zone: "center", render: renderCompactionsSegment, defaultShow: true },
170
- { id: "tokens_saved", label: "Tokens Saved", shortLabel: "SVD", description: "Tokens saved by compaction", zone: "center", render: renderTokensSavedSegment, defaultShow: true },
171
- { id: "compression_ratio", label: "Compression Ratio", shortLabel: "RAT", description: "Last compaction compression ratio", zone: "center", render: renderCompressionRatioSegment, defaultShow: false },
172
- { id: "sandbox_runs", label: "Sandbox Runs", shortLabel: "SBX", description: "Number of sandbox code runs", zone: "center", render: renderSandboxRunsSegment, defaultShow: false },
173
- { id: "search_queries", label: "Search Queries", shortLabel: "QRY", description: "Number of search queries", zone: "center", render: renderSearchQueriesSegment, defaultShow: false },
75
+ {
76
+ id: "compactions",
77
+ label: "Compactions",
78
+ shortLabel: "CMP",
79
+ description: "Compactions this session: count, tokens before → after, time since the last (hidden until the first)",
80
+ zone: "center",
81
+ render: renderCompactionsSegment,
82
+ defaultShow: true,
83
+ },
174
84
  ];
@@ -23,7 +23,6 @@ const STATUS_DISPLAY: Record<string, { short: string; segmentId: string }> = {
23
23
  workflow: { short: "WF", segmentId: "currentCommand" },
24
24
  ralph: { short: "RL", segmentId: "activeLoops" },
25
25
  memory: { short: "MEM", segmentId: "projectCount" },
26
- compactor: { short: "CMP", segmentId: "compactions" },
27
26
  mcp: { short: "MCP", segmentId: "serversTotal" },
28
27
  notify: { short: "NTF", segmentId: "platformsEnabled" },
29
28
  kanboard: { short: "KB", segmentId: "docsCount" },