@pi-unipi/footer 2.11.0 → 2.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.
package/README.md CHANGED
@@ -4,6 +4,22 @@ Persistent status bar at the bottom of the terminal. Shows live stats from all U
4
4
 
5
5
  Subscribes to events from every package and renders segments using Pi's `setFooter` + `setWidget` APIs. Responsive layout adjusts to terminal width, with a secondary row for narrow terminals.
6
6
 
7
+ ## Glance Footer (new in 2.12)
8
+
9
+ An experimental input surface, on by default and toggleable in `/unipi:footer-settings` → Appearance → **Glance Footer**:
10
+
11
+ ```
12
+ ╭─ 󰚩 UNIPI │ feat/footer-default-v2 │ ───────────────────────────╮
13
+ │ Type your prompt here... │
14
+ ╰─ 󰉋 unipi ────────────────────── 42%/1.0M │ GLM-5.3 │ thinking:high ─╯
15
+ 2 turn · 20 steps | 00:12:14 · tool 00:02 | 20ms avg ttft · 120 tok/s | 90% cache hit
16
+ ```
17
+
18
+ - **Top border:** animated lolcat-gradient UNIPI brand + git branch (turns rainbow-frame animated while thinking is max/xhigh)
19
+ - **Bottom border:** workspace · context %/window · model · thinking level
20
+ - **Session strip:** turns/steps, wall + tool wall time, average TTFT, tok/s, cache hit % — colored per stat, honest across restarts (derived from persisted session timestamps when live hooks are unavailable; provider-reported `usage.output` anchors token counts whenever present)
21
+ - The classic segment status line is suppressed while glance mode is on; toggle it back for the classic footer
22
+
7
23
  ## Commands
8
24
 
9
25
  | Command | Description |
@@ -36,11 +52,11 @@ Footer works even if packages load after it — late-arriving events update the
36
52
 
37
53
  | Preset | Description |
38
54
  |--------|-------------|
39
- | `default` | Balanced: model, thinking, path, git, context, cost + compactor + memory + ralph |
55
+ | `default` | Glance-era: UNIPI brand, model, thinking, directory, git \| context/tokens, tps, cost, clock |
56
+ | `classic` | The pre-2.12 balanced layout: model, api, tools, git \| tps, context, cost + compactor + memory + ralph |
40
57
  | `minimal` | Essentials only: path, git, context |
41
- | `compact` | Core + key stats: model, git, cost, context + compactor + memory |
58
+ | `compact` | Core + key stats: model, git, cost, context |
42
59
  | `full` | Everything from all groups |
43
- | `nerd` | Full + hostname + time + session + extensions |
44
60
  | `ascii` | Core segments with ASCII icons |
45
61
 
46
62
  ## Segment Groups
@@ -67,6 +83,7 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
67
83
  "footer": {
68
84
  "enabled": true,
69
85
  "preset": "default",
86
+ "glanceMode": true,
70
87
  "separator": "powerline-thin",
71
88
  "iconStyle": "nerd",
72
89
  "colorMode": "auto",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.11.0",
3
+ "version": "2.12.1",
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,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.11.0"
35
+ "@pi-unipi/core": "2.12.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.84.0",
package/src/commands.ts CHANGED
@@ -11,20 +11,7 @@ import { loadFooterSettings, saveFooterSettings } from "./config.js";
11
11
  import { showFooterSettings } from "./tui/settings-tui.js";
12
12
  import { showFooterHelp } from "./help.js";
13
13
  import type { FooterGroup, FooterSegment } from "./types.js";
14
-
15
- /** Extension state interface */
16
- interface FooterState {
17
- enabled: boolean;
18
- renderer: {
19
- setPreset(name: string): void;
20
- setActive(active: boolean): void;
21
- getPresetName(): string;
22
- resetLayoutCache(): void;
23
- };
24
- segmentLookup: Map<string, FooterSegment>;
25
- piContext: unknown;
26
- setupUI: ((pi: ExtensionAPI, ctx: ExtensionContext) => void) | null;
27
- }
14
+ import { applyGlanceMode, type FooterState } from "./index.js";
28
15
 
29
16
  /**
30
17
  * Register footer commands.
@@ -91,10 +78,17 @@ export function registerCommands(
91
78
 
92
79
  if (groups && groups.length > 0) {
93
80
  showFooterSettings(ctx, groups, () => {
94
- // Re-read settings and update renderer
81
+ // Instant, focus-safe updates: mode flag + renderer reset. Widget
82
+ // renders read state.glanceMode every frame, so the strip/classic
83
+ // row swap is live immediately. The EDITOR component swap is
84
+ // deferred to overlay close — see .finally below.
95
85
  const updated = loadFooterSettings();
96
86
  state.renderer.setPreset(updated.preset);
87
+ state.glanceMode = updated.glanceMode !== false;
97
88
  state.renderer.resetLayoutCache();
89
+ }).finally(() => {
90
+ // Overlay closed → focus back on the editor; safe to swap it now.
91
+ applyGlanceMode(state, ctx as unknown as Parameters<typeof applyGlanceMode>[1]);
98
92
  });
99
93
  } else {
100
94
  // Fallback: show text summary
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@ import { UNIPI_SETTINGS_KEY } from "@pi-unipi/core";
15
15
  export const DEFAULT_FOOTER_SETTINGS: FooterSettings = {
16
16
  enabled: true,
17
17
  preset: "default",
18
+ glanceMode: true,
18
19
  separator: "powerline-thin",
19
20
  iconStyle: "nerd",
20
21
  zoneSeparator: "\u2502", // │
@@ -93,6 +94,7 @@ export function loadFooterSettings(): FooterSettings {
93
94
  return {
94
95
  enabled: typeof footer.enabled === "boolean" ? footer.enabled : DEFAULT_FOOTER_SETTINGS.enabled,
95
96
  preset: typeof footer.preset === "string" ? footer.preset : DEFAULT_FOOTER_SETTINGS.preset,
97
+ glanceMode: typeof footer.glanceMode === "boolean" ? footer.glanceMode : DEFAULT_FOOTER_SETTINGS.glanceMode,
96
98
  separator: isValidSeparator(footer.separator) ? footer.separator as SeparatorStyle : DEFAULT_FOOTER_SETTINGS.separator,
97
99
  iconStyle: isValidIconStyle(footer.iconStyle) ? footer.iconStyle as IconStyle : DEFAULT_FOOTER_SETTINGS.iconStyle,
98
100
  zoneSeparator: typeof footer.zoneSeparator === "string" ? footer.zoneSeparator : DEFAULT_FOOTER_SETTINGS.zoneSeparator,
@@ -0,0 +1,285 @@
1
+ /**
2
+ * @pi-unipi/footer — Glance-style input surface
3
+ *
4
+ * Faithful to @zhcsyncer/pi-glance's frame architecture (MIT, © 2026 linys77):
5
+ * subclass CustomEditor, use super.render() ONLY to obtain editor content
6
+ * lines, then compose our own rounded frame. Layout per the v3 design:
7
+ *
8
+ * ╭─UNIPI │ feat/footer-default-v2 │ ─────────────────────╮
9
+ * │ Type your prompt here... │
10
+ * ╰─ unipi ────────── 42%/1.0M │ Claude Opus 4.5 │ thinking:high ╯
11
+ *
12
+ * Top border carries the UNIPI brand + git branch. Bottom border right side
13
+ * carries workspace · context%/window · model · thinking level; left filler.
14
+ * All keybindings/autocomplete/history/paste behavior inherited.
15
+ */
16
+
17
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
18
+ import type { EditorOptions, EditorTheme, TUI } from "@earendil-works/pi-tui";
19
+ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
20
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
+ import { getIcon } from "./rendering/icons.js";
22
+
23
+ /** Live status injected by the footer extension. */
24
+ export interface GlanceStatus {
25
+ /** Workspace/project directory name (bottom-left). */
26
+ workspace: string;
27
+ /** Git branch for the top title (null → omitted). */
28
+ branch: string | null;
29
+ /** Context usage percent (null when unknown). */
30
+ contextPct: number | null;
31
+ /** Context window size in tokens (for "42%/1.0M"). */
32
+ contextWindow: number;
33
+ /** Model display name (short). */
34
+ modelName: string;
35
+ /** Current thinking level (already "off"-filtered by the provider). */
36
+ thinkingLevel: string | null;
37
+ }
38
+
39
+ const BORDER = {
40
+ topLeft: "╭",
41
+ topRight: "╮",
42
+ bottomLeft: "╰",
43
+ bottomRight: "╯",
44
+ vertical: "│",
45
+ horizontal: "─",
46
+ } as const;
47
+
48
+ const SEP = " \u2502 "; // │
49
+
50
+ /**
51
+ * Every invisible sequence pi-tui embeds in editor lines: CSI SGR, OSC 133
52
+ * prompt-zone markers (`\x1b]133;A\x07`), and CURSOR_MARKER (`\x1b_pi:c\x07`,
53
+ * an APC sequence for IME cursor placement). All are zero-width — they must
54
+ * be skipped by BOTH painting and width math or the ledger drifts.
55
+ */
56
+ const ANSI_RE = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\)|_[^\x07]*(?:\x07|\x1b\\))/g;
57
+
58
+ function stripControls(text: string): string {
59
+ return text.replace(ANSI_RE, "").replace(/[\r\n\t]/g, " ");
60
+ }
61
+
62
+ function repeat(s: string, n: number): string {
63
+ return s.repeat(Math.max(0, n));
64
+ }
65
+
66
+ function padLine(line: string, width: number): string {
67
+ const w = visibleWidth(line);
68
+ if (w === width) return line;
69
+ if (w < width) return line + " ".repeat(width - w);
70
+ return truncateToWidth(line, width, "");
71
+ }
72
+
73
+ function fmtTokens(n: number): string {
74
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
75
+ if (n >= 1_000) return `${Math.round(n / 1000)}k`;
76
+ return String(n);
77
+ }
78
+
79
+ // ─── Lolcat rainbow gradient (classic sine algorithm, animated) ─────────
80
+ //
81
+ // red = sin(freq·i + phase) · 127 + 128
82
+ // green = sin(freq·i + 2π/3 + phase) · 127 + 128
83
+ // blue = sin(freq·i + 4π/3 + phase) · 127 + 128
84
+ // i = character position; phase advances with wall time so the gradient
85
+ // drifts across the word (lolcat -a behavior). Rendered as truecolor SGR.
86
+
87
+ const LOLCAT_FREQ = 0.9; // radians per char — short word → tight sweep
88
+
89
+ function lolcatRainbow(text: string, phase: number): string {
90
+ return text
91
+ .split("")
92
+ .map((ch, i) => {
93
+ const t = LOLCAT_FREQ * i + phase;
94
+ const r = Math.round(Math.sin(t) * 127 + 128);
95
+ const g = Math.round(Math.sin(t + (2 * Math.PI) / 3) * 127 + 128);
96
+ const b = Math.round(Math.sin(t + (4 * Math.PI) / 3) * 127 + 128);
97
+ return `\x1b[38;2;${r};${g};${b}m${ch}`;
98
+ })
99
+ .join("") + "\x1b[39m";
100
+ }
101
+
102
+ export class GlanceEditor extends CustomEditor {
103
+ private glance: () => GlanceStatus;
104
+
105
+ constructor(
106
+ tui: TUI,
107
+ theme: EditorTheme,
108
+ keybindings: KeybindingsManager,
109
+ getStatus: () => GlanceStatus,
110
+ options?: EditorOptions,
111
+ ) {
112
+ super(tui, theme, keybindings, options);
113
+ this.glance = getStatus;
114
+ }
115
+
116
+ /**
117
+ * Apply a flowing lolcat gradient to an already-composed frame line.
118
+ * Printable characters get per-position truecolor from the sine palette;
119
+ * existing SGR sequences pass through untouched (they mostly come from
120
+ * the brand itself, which already carries its own colors).
121
+ */
122
+ protected paintLolcatLine(line: string, phaseBase: number): string {
123
+ let out = "";
124
+ let pos = 0;
125
+ let i = 0;
126
+ while (i < line.length) {
127
+ const ch = line[i];
128
+ if (ch === "\x1b") {
129
+ const rest = line.slice(i);
130
+ // Copy the whole escape sequence verbatim.
131
+ const m = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(line.slice(i));
132
+ if (m) {
133
+ out += m[0];
134
+ i += m[0].length;
135
+ continue;
136
+ }
137
+ // Zero-width string sequences: OSC 133 zone markers (\x1b]133;A
138
+ // BEL) and the APC CURSOR_MARKER (\x1b_pi:c BEL). Painting their
139
+ // bytes is what produced the "_pi:c" garbage in rainbow mode.
140
+ const strSeq = /^\x1b[\]_][^\x07]*(?:\x07|\x1b\\)/.exec(rest);
141
+ if (strSeq) {
142
+ out += strSeq[0];
143
+ i += strSeq[0].length;
144
+ continue;
145
+ }
146
+ }
147
+ const t = LOLCAT_FREQ * 0.35 * pos + phaseBase;
148
+ const r = Math.round(Math.sin(t) * 127 + 128);
149
+ const g = Math.round(Math.sin(t + (2 * Math.PI) / 3) * 127 + 128);
150
+ const b = Math.round(Math.sin(t + (4 * Math.PI) / 3) * 127 + 128);
151
+ out += `\u001b[38;2;${r};${g};${b}m${ch}`;
152
+ pos++;
153
+ i++;
154
+ }
155
+ return out + "\u001b[39m";
156
+ }
157
+
158
+ private static isThinkingHot(level: string | null | undefined): boolean {
159
+ return level === "max" || level === "xhigh";
160
+ }
161
+
162
+ override render(width: number): string[] {
163
+ const safe = Math.max(8, width);
164
+ const inner = Math.max(0, safe - 2);
165
+
166
+ // Base render at inner width: harvest content rows only (rules dropped).
167
+ const base = super.render(inner);
168
+ if (base.length < 2) return base;
169
+
170
+ const isRule = (line: string): boolean => {
171
+ const plain = stripControls(line).trim();
172
+ return plain.length > 0 && /^─+$/.test(plain);
173
+ };
174
+
175
+ let bottomIdx = -1;
176
+ for (let i = base.length - 1; i >= 0; i--) {
177
+ const plain = stripControls(base[i]).trim();
178
+ if (/^─*(↑|↓)?/.test(plain) && (isRule(base[i]) || /^[^─]*─+[^─]*$/.test(plain))) {
179
+ bottomIdx = i;
180
+ break;
181
+ }
182
+ }
183
+ if (bottomIdx < 1) return base;
184
+
185
+ const contentLines = base.slice(0, bottomIdx).filter(l => !isRule(l));
186
+ const autocomplete = base.slice(bottomIdx + 1);
187
+
188
+ const st = this.glance();
189
+ const border = this.borderColor.bind(this);
190
+
191
+ // ── Top frame: ╭─ 󰚩 UNIPI │ feat/... │ ───────────────╮ ──
192
+ // Brand rendered as an animated lolcat gradient (phase from wall time;
193
+ // the footer's 1s refresh timer re-renders, so it shimmers each tick).
194
+ const brand = lolcatRainbow("UNIPI", Date.now() / 1000);
195
+ const brandIcon = getIcon("model");
196
+ const titleParts = [
197
+ `${brandIcon ? brandIcon + " " : ""}${brand}`,
198
+ ];
199
+ if (st.branch) {
200
+ const gitIcon = getIcon("git");
201
+ titleParts.push(`${gitIcon ? gitIcon + " " : ""}${st.branch}`);
202
+ }
203
+ const title = titleParts.join(SEP);
204
+ const leadRule = `${BORDER.horizontal} `;
205
+ const titleText = ` ${title}${SEP}`;
206
+ // Width ledger counts PLAIN text — strip SGR (rainbow codes) first.
207
+ const topFiller = Math.max(
208
+ 2,
209
+ safe - 2 - visibleWidth(stripControls(leadRule + titleText)),
210
+ );
211
+ const top =
212
+ border(BORDER.topLeft) +
213
+ border(leadRule + titleText) +
214
+ border(repeat(BORDER.horizontal, topFiller)) +
215
+ border(BORDER.topRight);
216
+
217
+ // ── Body rows: │ content │ ──
218
+ const bodyRows = contentLines.map(row =>
219
+ border(BORDER.vertical) + padLine(row, inner) + border(BORDER.vertical),
220
+ );
221
+
222
+ // ── Bottom frame: ╰─ unipi ─────── [RIGHT CLUSTER] ─╯ ──
223
+ // Right cluster: workspace · pct%/window │ model │ thinking:level
224
+ const rightParts: string[] = [];
225
+ const ctxPct = st.contextPct !== null ? Math.max(0, Math.min(100, st.contextPct)) : null;
226
+ const pctLabel = ctxPct !== null ? `${Math.round(ctxPct)}%` : "?%";
227
+ const winLabel = st.contextWindow > 0 ? `/${fmtTokens(st.contextWindow)}` : "";
228
+ rightParts.push(`${pctLabel}${winLabel}`);
229
+ if (st.modelName) rightParts.push(st.modelName);
230
+ if (st.thinkingLevel && st.thinkingLevel !== "off") {
231
+ rightParts.push(`thinking:${st.thinkingLevel}`);
232
+ }
233
+ // Workspace gets the folder nerd icon.
234
+ const dirIcon = getIcon("directory");
235
+ const leftTitle = ` ${dirIcon ? dirIcon + " " : ""}${st.workspace} `;
236
+ let cluster = rightParts.join(SEP);
237
+
238
+ // Shrink policy for narrow terminals: thinking first, then model tail.
239
+ const maxCluster = inner - visibleWidth(leftTitle) - 8;
240
+ while (visibleWidth(cluster) > Math.max(10, maxCluster)) {
241
+ const parts = cluster.split("│");
242
+ if (parts.length <= 1) break;
243
+ parts.pop();
244
+ cluster = parts.join("\u2502").trimEnd();
245
+ }
246
+ // ╰─ 󰉋 unipi ────...──── ─ cluster ─╯ : leading space separates the rule
247
+ // from the right cluster. Ledger (all plain-width):
248
+ // corner(1)+rule(1)+leftTitle+dashes+space(1)+cluster+space(1)+rule(1)+corner(1)=safe
249
+ const bottomDashes = Math.max(
250
+ 2,
251
+ safe - 6 - visibleWidth(stripControls(leftTitle)) - visibleWidth(stripControls(cluster)),
252
+ );
253
+
254
+ const bottom =
255
+ border(BORDER.bottomLeft) +
256
+ border(BORDER.horizontal) +
257
+ border(leftTitle) +
258
+ border(repeat(BORDER.horizontal, bottomDashes)) +
259
+ " " + cluster + " " +
260
+ border(BORDER.horizontal) +
261
+ border(BORDER.bottomRight);
262
+
263
+ const lines = [
264
+ truncateToWidth(top, safe),
265
+ ...bodyRows,
266
+ truncateToWidth(bottom, safe),
267
+ ];
268
+
269
+ // Thinking max/xhigh: the whole frame flows with an animated lolcat
270
+ // gradient (phase from wall time; 1s refresh ticks drive the motion;
271
+ // row offset makes the rainbow flow diagonally like full-screen lolcat).
272
+ if (GlanceEditor.isThinkingHot(st.thinkingLevel)) {
273
+ const phase = Date.now() / 700;
274
+ for (let i = 0; i < lines.length; i++) {
275
+ lines[i] = truncateToWidth(this.paintLolcatLine(lines[i], phase + i * 0.5), safe);
276
+ }
277
+ }
278
+
279
+ // Autocomplete list indents by 1 col and sits below the frame (like base).
280
+ for (const line of autocomplete) {
281
+ lines.push(padLine(` ${line}`, safe));
282
+ }
283
+ return lines;
284
+ }
285
+ }
package/src/help.ts CHANGED
@@ -73,7 +73,7 @@ function buildHelpLines(
73
73
 
74
74
  /** Simple group lookup for help */
75
75
  export function getGroupForSegment(segId: string): string {
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"];
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
78
  const compactorIds = ["session_events", "compactions", "tokens_saved", "compression_ratio", "sandbox_runs", "search_queries"];
79
79
  if (compactorIds.includes(segId)) return "compactor";