@pi-unipi/unipi 2.10.2 → 2.12.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/package.json +23 -23
  4. package/packages/ask-user/package.json +2 -2
  5. package/packages/autocomplete/package.json +1 -1
  6. package/packages/background-tasks/package.json +2 -2
  7. package/packages/btw/package.json +2 -2
  8. package/packages/compactor/package.json +3 -3
  9. package/packages/core/package.json +1 -1
  10. package/packages/footer/README.md +20 -3
  11. package/packages/footer/package.json +2 -2
  12. package/packages/footer/src/commands.ts +9 -15
  13. package/packages/footer/src/config.ts +2 -0
  14. package/packages/footer/src/glance-editor.ts +285 -0
  15. package/packages/footer/src/help.ts +1 -1
  16. package/packages/footer/src/index.ts +360 -27
  17. package/packages/footer/src/presets.ts +16 -1
  18. package/packages/footer/src/rendering/icons.ts +8 -0
  19. package/packages/footer/src/rendering/theme.ts +2 -0
  20. package/packages/footer/src/segments/core.ts +114 -7
  21. package/packages/footer/src/tps-tracker.ts +529 -240
  22. package/packages/footer/src/tui/settings-tui.ts +18 -2
  23. package/packages/footer/src/types.ts +4 -0
  24. package/packages/image/package.json +2 -2
  25. package/packages/info-screen/package.json +2 -2
  26. package/packages/input-shortcuts/package.json +2 -2
  27. package/packages/kanboard/package.json +2 -2
  28. package/packages/mcp/package.json +2 -2
  29. package/packages/memory/package.json +3 -3
  30. package/packages/milestone/package.json +2 -2
  31. package/packages/notify/package.json +2 -2
  32. package/packages/ralph/package.json +3 -3
  33. package/packages/subagents/package.json +2 -2
  34. package/packages/trajectory/README.md +23 -7
  35. package/packages/trajectory/index.ts +23 -3
  36. package/packages/trajectory/package.json +1 -1
  37. package/packages/trajectory/src/capture.ts +150 -30
  38. package/packages/trajectory/src/prefix-integrity.ts +213 -0
  39. package/packages/trajectory/src/project.ts +57 -5
  40. package/packages/trajectory/src/server.ts +10 -3
  41. package/packages/trajectory/src/telemetry.ts +79 -18
  42. package/packages/trajectory/src/tracer.ts +401 -0
  43. package/packages/trajectory/src/ui.ts +14 -12
  44. package/packages/unipi/bundled.js +10080 -3406
  45. package/packages/unipi/index.ts +25 -22
  46. package/packages/updater/package.json +2 -2
  47. package/packages/utility/package.json +2 -2
  48. package/packages/web-api/package.json +2 -2
  49. package/packages/workflow/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -6,6 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [2.12.0] — 2026-08-27
10
+
11
+ ### Added
12
+
13
+ - `footer`: **Glance footer mode** (default on, toggle in `/unipi:footer-settings` → Appearance → "Glance Footer") — a framed input surface inspired by pi-glance. Top border shows a lolcat-animated UNIPI brand plus the git branch; bottom border carries workspace directory, context %/window, model, and thinking level; the whole frame flows with an animated rainbow gradient while thinking is max/xhigh.
14
+ - `footer`: **session strip** centered under the input — turns · steps | wall time · tool time | avg TTFT · tok/s | cache hit %, with per-stat coloring (tok/s tiered by speed, cache hit by hit rate).
15
+ - `footer`: restart-honest session statistics derived from persisted session entries (turns = user messages, steps = assistant messages, wall/tool/TTFT windows from entry timestamps), so numbers survive `pi -r` without live hook support. Live hooks still refine precision when available.
16
+ - `footer`: git branch adornments from a cached 2s porcelain probe — dirty `*`, ahead `↑N`, behind `↓N` on any preset showing the branch segment.
17
+ - `footer`: new default preset (UNIPI brand · model · thinking · directory · branch | context/tokens · tps · cost · clock) with the previous balanced layout preserved as the `classic` preset.
18
+ - `footer`: `uni` (brand mark) and `directory` segments, nerd/emoji/text icon entries for both.
19
+
20
+ ### Changed
21
+
22
+ - `footer`: TPS token accounting anchored to provider-reported `usage.output` at stream end; density chars/4 estimate only fills streaming gaps. Timing windows measure output-only spans (first delta → stream end) so tool execution, queueing, and time-to-first-token no longer dilute rates. Session average clamps pathological reconstructed durations (600s) and prefers real next-entry deltas after restarts — tok/s stays truthful across sessions for any provider that reports usage.
23
+ - `footer`: TTFT measured per deepseek-harness semantics — agent turn start → first non-empty delta, averaged only over samples where both bounds exist; scan-derived seeds (clamped 30s) fill in when turn hooks are unavailable.
24
+
25
+ ### Fixed
26
+
27
+ - `footer`: frozen/stale TPS after session resume or branch switch — streaming hooks and the reconciliation scan keyed tracker records with different index schemes; both now share a synchronized cursor (`cursorSyncCount`).
28
+ - `footer`: info dashboard unclosable when extensions replaced the editor during startup focus handoff — glance editor install is deferred past boot overlay auto-close, and settings-driven swaps wait until the settings overlay closes (`setEditorComponent` steals keyboard focus by design).
29
+ - `footer`: rainbow frame garbled zero-width IME cursor markers and OSC-133 zone sequences in max/xhigh thinking mode; the painter now passes all escape forms through byte-identical.
30
+
31
+ ## [2.11.0] — 2026-08-26
32
+
33
+ ### Added
34
+
35
+ - `trajectory`: full UniPi-owned package attribution for lifecycle hooks, command handlers, tool execution, mutating `ExtensionAPI` calls, context/session actions, and custom EventBus delivery, including duration, result fingerprints, errors, and bounded mutation evidence.
36
+ - `trajectory`: per-request prefix-integrity verdicts (`first_request`, `identical_retry`, `prefix_extended`, `boundary`, `violation`) over final serialized provider payloads. Violations identify the first changed message index/path and separately report system prompt, tool schema/order, provider route, thinking level, and envelope drift.
37
+ - `trajectory`: explicit cache epochs for compaction, tree navigation, model changes, and thinking-level changes; append-only enforcement resumes immediately after each boundary.
38
+ - `trajectory`: **UniPi** and **Violations** filters plus Trace, Integrity, and Attribution inspector tabs.
39
+
40
+ ### Changed
41
+
42
+ - Umbrella `@pi-unipi/unipi` now loads every owned module through a package-scoped tracing API and keeps trajectory as the final unwrapped sink. Unrelated third-party extensions are intentionally excluded from attribution.
43
+ - Trajectory is now context-focused: the ledger includes provider-visible messages, system prompts, serialized requests, prefix verdicts, and only UniPi operations that mutate context or establish a cache epoch. Inert lifecycle hooks, registrations, notifications, response headers, and stream deltas are excluded.
44
+ - Trajectory live updates now cache the telemetry tail and projected snapshot, read only the last 5 MB of existing sidecars, use conditional 1.5-second polling, and avoid rerendering unchanged pages.
45
+
9
46
  ## [2.10.2] — 2026-08-25
10
47
 
11
48
  Follow-up to 2.10.1: the 2.10.1 tarballs shipped with `@pi-unipi/*` cross-dependency pins still pointing at `2.10.0`, so installing `@pi-unipi/unipi@2.10.1` resolved the old `@pi-unipi/trajectory@2.10.0` (without the auto-completion fix) instead of `2.10.1`. This release re-synchronizes every cross-pin to `2.10.2` and republishes the full synchronized set.
package/README.md CHANGED
@@ -33,7 +33,7 @@ pi install npm:@pi-unipi/unipi
33
33
 
34
34
  **[Notify](./packages/notify/README.md)** — Push notifications to native OS, Gotify, Telegram, or ntfy. Per-event platform routing plus native focus suppression so alerts can stay quiet while Pi is already focused.
35
35
 
36
- **[Footer](./packages/footer/README.md)** — Persistent status bar showing live stats from every package. Responsive layout, presets, per-segment toggling, and terminal-aware color fallback.
36
+ **[Footer](./packages/footer/README.md)** — Persistent status bar showing live stats from every package. Now with the Glance footer: a framed input box with an animated rainbow brand, git branch title, and a live session strip (turns, wall/tool time, TTFT, tok/s, cache hit). Responsive layout, presets, per-segment toggling.
37
37
 
38
38
  **[BTW](./packages/btw/README.md)** — Side conversations that run in parallel. Ask questions without interrupting the main agent.
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/unipi",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "All-in-one extension suite for Pi coding agent",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -82,28 +82,28 @@
82
82
  "typebox": "^1.1.38"
83
83
  },
84
84
  "dependencies": {
85
- "@pi-unipi/ask-user": "2.10.2",
86
- "@pi-unipi/background-tasks": "2.10.2",
87
- "@pi-unipi/btw": "2.10.2",
88
- "@pi-unipi/command-enchantment": "2.10.2",
89
- "@pi-unipi/compactor": "2.10.2",
90
- "@pi-unipi/core": "2.10.2",
91
- "@pi-unipi/footer": "2.10.2",
92
- "@pi-unipi/image": "2.10.2",
93
- "@pi-unipi/info-screen": "2.10.2",
94
- "@pi-unipi/input-shortcuts": "2.10.2",
95
- "@pi-unipi/kanboard": "2.10.2",
96
- "@pi-unipi/mcp": "2.10.2",
97
- "@pi-unipi/memory": "2.10.2",
98
- "@pi-unipi/milestone": "2.10.2",
99
- "@pi-unipi/notify": "2.10.2",
100
- "@pi-unipi/ralph": "2.10.2",
101
- "@pi-unipi/subagents": "2.10.2",
102
- "@pi-unipi/trajectory": "2.10.2",
103
- "@pi-unipi/updater": "2.10.2",
104
- "@pi-unipi/utility": "2.10.2",
105
- "@pi-unipi/web-api": "2.10.2",
106
- "@pi-unipi/workflow": "2.10.2"
85
+ "@pi-unipi/ask-user": "2.11.0",
86
+ "@pi-unipi/background-tasks": "2.11.0",
87
+ "@pi-unipi/btw": "2.11.0",
88
+ "@pi-unipi/command-enchantment": "2.11.0",
89
+ "@pi-unipi/compactor": "2.11.0",
90
+ "@pi-unipi/core": "2.11.0",
91
+ "@pi-unipi/footer": "2.11.0",
92
+ "@pi-unipi/image": "2.11.0",
93
+ "@pi-unipi/info-screen": "2.11.0",
94
+ "@pi-unipi/input-shortcuts": "2.11.0",
95
+ "@pi-unipi/kanboard": "2.11.0",
96
+ "@pi-unipi/mcp": "2.11.0",
97
+ "@pi-unipi/memory": "2.11.0",
98
+ "@pi-unipi/milestone": "2.11.0",
99
+ "@pi-unipi/notify": "2.11.0",
100
+ "@pi-unipi/ralph": "2.11.0",
101
+ "@pi-unipi/subagents": "2.11.0",
102
+ "@pi-unipi/trajectory": "2.11.0",
103
+ "@pi-unipi/updater": "2.11.0",
104
+ "@pi-unipi/utility": "2.11.0",
105
+ "@pi-unipi/web-api": "2.11.0",
106
+ "@pi-unipi/workflow": "2.11.0"
107
107
  },
108
108
  "devDependencies": {
109
109
  "@earendil-works/pi-agent-core": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/ask-user",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Structured user input tool for Pi coding agent — single-select, multi-select, freeform",
5
5
  "type": "module",
6
6
  "main": "index.ts",
@@ -40,7 +40,7 @@
40
40
  "access": "public"
41
41
  },
42
42
  "dependencies": {
43
- "@pi-unipi/core": "2.10.2"
43
+ "@pi-unipi/core": "2.12.0"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/background-tasks",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Background tasks for UniPi — durable shell jobs, delegated agents, attested Pi runs, and fixed-purpose Fusion workflows",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "@earendil-works/pi-ai": "^0.84.0",
13
- "@pi-unipi/core": "2.10.2",
13
+ "@pi-unipi/core": "2.12.0",
14
14
  "turndown": "^7.2.4"
15
15
  },
16
16
  "devDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.84.0"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "2.10.2"
40
+ "@pi-unipi/core": "2.12.0"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -34,8 +34,8 @@
34
34
  "access": "public"
35
35
  },
36
36
  "dependencies": {
37
- "@pi-unipi/core": "2.10.2",
38
- "@pi-unipi/info-screen": "2.10.2",
37
+ "@pi-unipi/core": "2.12.0",
38
+ "@pi-unipi/info-screen": "2.12.0",
39
39
  "@earendil-works/pi-agent-core": "^0.84.0"
40
40
  },
41
41
  "peerDependencies": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/core",
3
- "version": "2.10.2",
3
+ "version": "2.12.0",
4
4
  "description": "Shared utilities, event types, and constants for Unipi extension suite",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/footer",
3
- "version": "2.10.2",
3
+ "version": "2.12.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,7 +32,7 @@
32
32
  "access": "public"
33
33
  },
34
34
  "dependencies": {
35
- "@pi-unipi/core": "2.10.2"
35
+ "@pi-unipi/core": "2.12.0"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@earendil-works/pi-coding-agent": "^0.84.0",
@@ -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
@@ -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
+ }
@@ -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";