@pi-unipi/footer 2.14.2 → 2.16.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/README.md CHANGED
@@ -26,11 +26,9 @@ An experimental input surface, on by default and toggleable in `/unipi:footer-se
26
26
  | Command | Description |
27
27
  |---------|-------------|
28
28
  | `/unipi:footer` | Toggle footer on/off |
29
- | `/unipi:footer <preset>` | Switch preset (default, minimal, compact, full, nerd, ascii) |
30
- | `/unipi:footer sep:<style>` | Change separator style |
31
- | `/unipi:footer icon:<style>` | Change icon style (nerd, emoji, text) |
32
29
  | `/unipi:footer on` / `/unipi:footer off` | Enable/disable explicitly |
33
- | `/unipi:footer-settings` | Open settings TUI for per-group/per-segment toggles |
30
+ | `/unipi:footer-settings` | Open settings TUI preset, separator, icon style, glance mode, per-segment toggles |
31
+ | `/unipi:footer-help` | Show footer segment guide |
34
32
 
35
33
  ## Special Triggers
36
34
 
@@ -120,7 +118,7 @@ Settings in `~/.pi/agent/settings.json` under `unipi.footer`:
120
118
  |-------|-------------|
121
119
  | `nerd` | Nerd Font glyphs (auto-detected) |
122
120
  | `emoji` | Unicode symbols (works on most terminals) |
123
- | `text` | Plain text abbreviations (works everywhere) |
121
+ | `text` | Plain text labels (works everywhere). Glance frame drops the robot glyph: `UNIPI │ branch:main`, `workspace:unipi` |
124
122
 
125
123
  When `iconStyle` is not set, footer auto-detects Nerd Font support and defaults to `nerd` if available, `emoji` otherwise.
126
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.14.2",
3
+ "version": "2.16.0",
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": "2.14.2",
36
- "@pi-unipi/background-tasks": "2.14.2"
35
+ "@pi-unipi/core": "2.16.0",
36
+ "@pi-unipi/background-tasks": "2.16.0"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -18,7 +18,8 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent";
18
18
  import type { EditorOptions, EditorTheme, TUI } from "@earendil-works/pi-tui";
19
19
  import type { KeybindingsManager } from "@earendil-works/pi-coding-agent";
20
20
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
21
- import { getIcon } from "./rendering/icons.js";
21
+ import { getIcon, getResolvedIconStyle } from "./rendering/icons.js";
22
+ import { lolcatRainbow, paintLolcatGradient } from "./rendering/lolcat.js";
22
23
 
23
24
  /** Live status injected by the footer extension. */
24
25
  export interface GlanceStatus {
@@ -96,25 +97,35 @@ function fmtTokens(n: number): string {
96
97
 
97
98
  // ─── Lolcat rainbow gradient (classic sine algorithm, animated) ─────────
98
99
  //
99
- // red = sin(freq·i + phase) · 127 + 128
100
- // green = sin(freq·i + 2π/3 + phase) · 127 + 128
101
- // blue = sin(freq·i + 4π/3 + phase) · 127 + 128
102
- // i = character position; phase advances with wall time so the gradient
103
- // drifts across the word (lolcat -a behavior). Rendered as truecolor SGR.
100
+ // Rendered per glyph as truecolor SGR — see rendering/lolcat.ts (issue #34:
101
+ // painting iterates code points so emoji surrogate pairs survive).
104
102
 
105
- const LOLCAT_FREQ = 0.9; // radians per char — short word → tight sweep
106
-
107
- function lolcatRainbow(text: string, phase: number): string {
108
- return text
109
- .split("")
110
- .map((ch, i) => {
111
- const t = LOLCAT_FREQ * i + phase;
112
- const r = Math.round(Math.sin(t) * 127 + 128);
113
- const g = Math.round(Math.sin(t + (2 * Math.PI) / 3) * 127 + 128);
114
- const b = Math.round(Math.sin(t + (4 * Math.PI) / 3) * 127 + 128);
115
- return `\x1b[38;2;${r};${g};${b}m${ch}`;
116
- })
117
- .join("") + "\x1b[39m";
103
+ /**
104
+ * Compose the glance frame titles for the active icon style.
105
+ *
106
+ * - emoji/nerd: glyph prefix before brand, branch and workspace.
107
+ * - text: no robot glyph (brand word only); literal labels —
108
+ * `branch:main` on the top title, `workspace:unipi` bottom-left.
109
+ */
110
+ export function composeGlanceTitles(
111
+ brand: string,
112
+ branch: string | null,
113
+ workspace: string,
114
+ ): { titleParts: string[]; leftTitle: string } {
115
+ const titleParts: string[] = [];
116
+ if (getResolvedIconStyle() === "text") {
117
+ titleParts.push(brand);
118
+ if (branch) titleParts.push(`branch:${branch}`);
119
+ return { titleParts, leftTitle: ` workspace:${workspace} ` };
120
+ }
121
+ const brandIcon = getIcon("model");
122
+ titleParts.push(`${brandIcon ? brandIcon + " " : ""}${brand}`);
123
+ if (branch) {
124
+ const gitIcon = getIcon("git");
125
+ titleParts.push(`${gitIcon ? gitIcon + " " : ""}${branch}`);
126
+ }
127
+ const dirIcon = getIcon("directory");
128
+ return { titleParts, leftTitle: ` ${dirIcon ? dirIcon + " " : ""}${workspace} ` };
118
129
  }
119
130
 
120
131
  export class GlanceEditor extends CustomEditor {
@@ -133,44 +144,11 @@ export class GlanceEditor extends CustomEditor {
133
144
 
134
145
  /**
135
146
  * Apply a flowing lolcat gradient to an already-composed frame line.
136
- * Printable characters get per-position truecolor from the sine palette;
137
- * existing SGR sequences pass through untouched (they mostly come from
138
- * the brand itself, which already carries its own colors).
147
+ * Kept as a protected hook for subclasses; painting itself lives in
148
+ * rendering/lolcat.ts (code-point-safe issue #34).
139
149
  */
140
150
  protected paintLolcatLine(line: string, phaseBase: number): string {
141
- let out = "";
142
- let pos = 0;
143
- let i = 0;
144
- while (i < line.length) {
145
- const ch = line[i];
146
- if (ch === "\x1b") {
147
- const rest = line.slice(i);
148
- // Copy the whole escape sequence verbatim.
149
- const m = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(line.slice(i));
150
- if (m) {
151
- out += m[0];
152
- i += m[0].length;
153
- continue;
154
- }
155
- // Zero-width string sequences: OSC 133 zone markers (\x1b]133;A
156
- // BEL) and the APC CURSOR_MARKER (\x1b_pi:c BEL). Painting their
157
- // bytes is what produced the "_pi:c" garbage in rainbow mode.
158
- const strSeq = /^\x1b[\]_][^\x07]*(?:\x07|\x1b\\)/.exec(rest);
159
- if (strSeq) {
160
- out += strSeq[0];
161
- i += strSeq[0].length;
162
- continue;
163
- }
164
- }
165
- const t = LOLCAT_FREQ * 0.35 * pos + phaseBase;
166
- const r = Math.round(Math.sin(t) * 127 + 128);
167
- const g = Math.round(Math.sin(t + (2 * Math.PI) / 3) * 127 + 128);
168
- const b = Math.round(Math.sin(t + (4 * Math.PI) / 3) * 127 + 128);
169
- out += `\u001b[38;2;${r};${g};${b}m${ch}`;
170
- pos++;
171
- i++;
172
- }
173
- return out + "\u001b[39m";
151
+ return paintLolcatGradient(line, phaseBase);
174
152
  }
175
153
 
176
154
  private static isThinkingHot(level: string | null | undefined): boolean {
@@ -211,14 +189,7 @@ export class GlanceEditor extends CustomEditor {
211
189
  // Brand rendered as an animated lolcat gradient (phase from wall time;
212
190
  // the footer's 1s refresh timer re-renders, so it shimmers each tick).
213
191
  const brand = lolcatRainbow("UNIPI", Date.now() / 1000);
214
- const brandIcon = getIcon("model");
215
- const titleParts = [
216
- `${brandIcon ? brandIcon + " " : ""}${brand}`,
217
- ];
218
- if (st.branch) {
219
- const gitIcon = getIcon("git");
220
- titleParts.push(`${gitIcon ? gitIcon + " " : ""}${st.branch}`);
221
- }
192
+ const { titleParts, leftTitle } = composeGlanceTitles(brand, st.branch, st.workspace);
222
193
  const title = titleParts.join(SEP);
223
194
  const leadRule = `${BORDER.horizontal} `;
224
195
  const titleText = ` ${title}${SEP}`;
@@ -249,9 +220,7 @@ export class GlanceEditor extends CustomEditor {
249
220
  if (st.thinkingLevel && st.thinkingLevel !== "off") {
250
221
  rightParts.push(`thinking:${st.thinkingLevel}`);
251
222
  }
252
- // Workspace gets the folder nerd icon.
253
- const dirIcon = getIcon("directory");
254
- const leftTitle = ` ${dirIcon ? dirIcon + " " : ""}${st.workspace} `;
223
+ // Workspace label per icon style (glyph prefix, or workspace: in text mode).
255
224
  let cluster = rightParts.join(SEP);
256
225
 
257
226
  // Shrink policy for narrow terminals: thinking first, then model tail.
@@ -314,6 +314,11 @@ export function resolveIconStyle(configured?: string): "nerd" | "emoji" | "text"
314
314
  return detectNerdFontSupport() ? "nerd" : "emoji";
315
315
  }
316
316
 
317
+ /** Effective icon style in use — the current setting, or auto-detection. */
318
+ export function getResolvedIconStyle(): "nerd" | "emoji" | "text" {
319
+ return currentIconStyle ?? resolveIconStyle();
320
+ }
321
+
317
322
  /**
318
323
  * Get the icon for a segment by ID.
319
324
  * Uses the configured icon style, falling back to auto-detection.
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @pi-unipi/footer — Lolcat rainbow gradient painting
3
+ *
4
+ * Classic sine algorithm (lolcat -a): red/green/blue chase each other by
5
+ * 2π/3 across character position; phase advances with wall time so the 1s
6
+ * footer refresh ticks drive visible motion.
7
+ *
8
+ * Issue #34: painting must NEVER split a glyph. Emoji (🤖 U+1F916, 🔀
9
+ * U+1F500) are surrogate pairs — indexing the string per UTF-16 code unit
10
+ * let the rainbow renderer insert an SGR between the two halves, which
11
+ * terminals render as replacement characters. Painting therefore advances
12
+ * per code point (with emoji variation selectors kept attached) and copies
13
+ * ANSI/OSC/APC escape sequences verbatim.
14
+ */
15
+
16
+ /** Radians per char — short words → tight sweep. */
17
+ const LOLCAT_FREQ = 0.9;
18
+
19
+ const ANSI_CSI_RE = /^\x1b\[[0-?]*[ -/]*[@-~]/;
20
+ const ANSI_STRING_RE = /^\x1b[\]_][^\x07]*(?:\x07|\x1b\\)/;
21
+ const VARIATION_SELECTOR_16 = 0xfe0f;
22
+
23
+ /** UTF-16 length of the glyph starting at `i`: full code point, plus a
24
+ * trailing VS16 (⬇️ = U+2B07 U+FE0F) so the SGR emitted after the glyph
25
+ * can never detach emoji presentation from its base character. */
26
+ function glyphLength(line: string, i: number): number {
27
+ const cp = line.codePointAt(i)!;
28
+ let len = cp > 0xffff ? 2 : 1;
29
+ if (line.codePointAt(i + len) === VARIATION_SELECTOR_16) len += 1;
30
+ return len;
31
+ }
32
+
33
+ function sineColor(t: number, offset: number): number {
34
+ return Math.round(Math.sin(t + offset) * 127 + 128);
35
+ }
36
+
37
+ /** Per-character rainbow — used for the UNIPI brand word (ASCII only). */
38
+ export function lolcatRainbow(text: string, phase: number): string {
39
+ return text
40
+ .split("")
41
+ .map((ch, i) => {
42
+ const t = LOLCAT_FREQ * i + phase;
43
+ return `\x1b[38;2;${sineColor(t, 0)};${sineColor(t, (2 * Math.PI) / 3)};${sineColor(t, (4 * Math.PI) / 3)}m${ch}`;
44
+ })
45
+ .join("") + "\x1b[39m";
46
+ }
47
+
48
+ /**
49
+ * Apply a flowing lolcat gradient to an already-composed frame line.
50
+ * Printable glyphs get per-position truecolor from the sine palette;
51
+ * existing escape sequences pass through untouched (they mostly come from
52
+ * the brand itself, which already carries its own colors).
53
+ */
54
+ export function paintLolcatGradient(line: string, phaseBase: number): string {
55
+ let out = "";
56
+ let pos = 0;
57
+ let i = 0;
58
+ while (i < line.length) {
59
+ if (line[i] === "\x1b") {
60
+ // Copy escape sequences verbatim: CSI SGR, OSC 133 zone markers
61
+ // (\x1b]133;A BEL) and the APC CURSOR_MARKER (\x1b_pi:c BEL).
62
+ // Painting their bytes produced "_pi:c" garbage in rainbow mode.
63
+ const csi = ANSI_CSI_RE.exec(line.slice(i));
64
+ if (csi) {
65
+ out += csi[0];
66
+ i += csi[0].length;
67
+ continue;
68
+ }
69
+ const strSeq = ANSI_STRING_RE.exec(line.slice(i));
70
+ if (strSeq) {
71
+ out += strSeq[0];
72
+ i += strSeq[0].length;
73
+ continue;
74
+ }
75
+ }
76
+ const len = glyphLength(line, i);
77
+ const glyph = line.slice(i, i + len); // whole code point — never split a surrogate pair
78
+ const t = LOLCAT_FREQ * 0.35 * pos + phaseBase;
79
+ out += `\u001b[38;2;${sineColor(t, 0)};${sineColor(t, (2 * Math.PI) / 3)};${sineColor(t, (4 * Math.PI) / 3)}m${glyph}`;
80
+ pos++;
81
+ i += len;
82
+ }
83
+ return out + "\u001b[39m";
84
+ }
@@ -244,7 +244,7 @@ class FooterSettingsOverlay {
244
244
  {
245
245
  id: "iconStyle",
246
246
  label: "Icon Style",
247
- description: "Icon glyph set (nerd requires Nerd Font)",
247
+ description: "emoji · nerd (Nerd Font) · text (glance: branch:x, workspace:y)",
248
248
  currentValue: this.settings.iconStyle,
249
249
  values: ICON_STYLES,
250
250
  },