@pi-unipi/footer 2.20.5 → 3.0.0-alpha.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 +2 -2
- package/package.json +6 -6
- package/src/commands.ts +3 -47
- package/src/config.ts +89 -18
- package/src/events.ts +44 -0
- package/src/glance-editor.ts +68 -5
- package/src/index.ts +25 -3
- package/src/presets.ts +6 -6
- package/src/segments/core.ts +2 -0
- package/src/segments/long-horizon.ts +47 -0
- package/src/segments/memory.ts +23 -0
- package/src/tui/settings-tui.ts +0 -503
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Subscribes to events from every package and renders segments using Pi's `setFoot
|
|
|
6
6
|
|
|
7
7
|
## Glance Footer (new in 2.12)
|
|
8
8
|
|
|
9
|
-
An experimental input surface, on by default and toggleable in `/unipi:
|
|
9
|
+
An experimental input surface, on by default and toggleable in `/unipi:settings` (Footer) → **Glance mode**:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
12
|
╭─ UNIPI │ feat/footer-default-v2 │ ───────────────────────────╮
|
|
@@ -27,7 +27,7 @@ An experimental input surface, on by default and toggleable in `/unipi:footer-se
|
|
|
27
27
|
|---------|-------------|
|
|
28
28
|
| `/unipi:footer` | Toggle footer on/off |
|
|
29
29
|
| `/unipi:footer on` / `/unipi:footer off` | Enable/disable explicitly |
|
|
30
|
-
| `/unipi:
|
|
30
|
+
| `/unipi:settings` | Hub — preset, separator, zone separator, icon style, glance mode, per-segment toggles (Footer group) |
|
|
31
31
|
| `/unipi:footer-help` | Show footer segment guide |
|
|
32
32
|
|
|
33
33
|
## Special Triggers
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/footer",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Persistent status bar for Unipi
|
|
3
|
+
"version": "3.0.0-alpha.1",
|
|
4
|
+
"description": "Persistent status bar for Unipi \u2014 subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
7
7
|
"license": "MIT",
|
|
@@ -32,12 +32,12 @@
|
|
|
32
32
|
"access": "public"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@pi-unipi/core": "
|
|
36
|
-
"@pi-unipi/background-tasks": "
|
|
35
|
+
"@pi-unipi/core": "3.0.0-alpha.1",
|
|
36
|
+
"@pi-unipi/background-tasks": "3.0.0-alpha.1"
|
|
37
37
|
},
|
|
38
38
|
"peerDependencies": {
|
|
39
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
40
|
-
"@earendil-works/pi-tui": "^0.
|
|
39
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
40
|
+
"@earendil-works/pi-tui": "^0.87.1"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^25.6.0",
|
package/src/commands.ts
CHANGED
|
@@ -1,26 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @pi-unipi/footer — Commands
|
|
3
3
|
*
|
|
4
|
-
* Footer commands: /unipi:footer (toggle)
|
|
5
|
-
* /unipi:footer-settings.
|
|
4
|
+
* Footer commands: /unipi:footer (toggle) and /unipi:footer <preset>.
|
|
6
5
|
*/
|
|
7
6
|
|
|
8
7
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
8
|
import { UNIPI_PREFIX, FOOTER_COMMANDS } from "@pi-unipi/core";
|
|
10
9
|
import { loadFooterSettings, saveFooterSettings } from "./config.js";
|
|
11
|
-
import { showFooterSettings } from "./tui/settings-tui.js";
|
|
12
10
|
import { showFooterHelp } from "./help.js";
|
|
13
|
-
import type {
|
|
11
|
+
import type { FooterSegment } from "./types.js";
|
|
14
12
|
import { applyGlanceMode, type FooterState } from "./index.js";
|
|
15
13
|
|
|
16
14
|
/**
|
|
17
15
|
* Register footer commands.
|
|
18
16
|
*/
|
|
19
|
-
export function registerCommands(
|
|
20
|
-
pi: ExtensionAPI,
|
|
21
|
-
state: FooterState,
|
|
22
|
-
groups?: FooterGroup[],
|
|
23
|
-
): void {
|
|
17
|
+
export function registerCommands(pi: ExtensionAPI, state: FooterState): void {
|
|
24
18
|
// /unipi:footer — toggle on/off only
|
|
25
19
|
pi.registerCommand(`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, {
|
|
26
20
|
description: "Toggle footer on/off",
|
|
@@ -67,44 +61,6 @@ export function registerCommands(
|
|
|
67
61
|
},
|
|
68
62
|
});
|
|
69
63
|
|
|
70
|
-
// /unipi:footer-settings — open settings TUI
|
|
71
|
-
pi.registerCommand(`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`, {
|
|
72
|
-
description: "Open footer settings (toggle groups and segments)",
|
|
73
|
-
handler: async (_args, ctx) => {
|
|
74
|
-
if (!ctx.hasUI) {
|
|
75
|
-
ctx.ui.notify("Footer settings requires a TUI", "warning");
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (groups && groups.length > 0) {
|
|
80
|
-
showFooterSettings(ctx, groups, () => {
|
|
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.
|
|
85
|
-
const updated = loadFooterSettings();
|
|
86
|
-
state.renderer.setPreset(updated.preset);
|
|
87
|
-
state.glanceMode = updated.glanceMode !== false;
|
|
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]);
|
|
92
|
-
});
|
|
93
|
-
} else {
|
|
94
|
-
// Fallback: show text summary
|
|
95
|
-
const settings = loadFooterSettings();
|
|
96
|
-
const info = [
|
|
97
|
-
`Enabled: ${settings.enabled}`,
|
|
98
|
-
`Preset: ${state.renderer.getPresetName()}`,
|
|
99
|
-
`Separator: ${settings.separator}`,
|
|
100
|
-
`Icon: ${settings.iconStyle}`,
|
|
101
|
-
`Groups: ${Object.entries(settings.groups).filter(([, g]) => g.show).map(([id]) => id).join(", ")}`,
|
|
102
|
-
].join("\n");
|
|
103
|
-
ctx.ui.notify(info, "info");
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
|
|
108
64
|
// /unipi:footer-help — show help overlay
|
|
109
65
|
pi.registerCommand(`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`, {
|
|
110
66
|
description: "Show footer segment guide (icons, labels, descriptions)",
|
package/src/config.ts
CHANGED
|
@@ -9,7 +9,8 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import * as os from "node:os";
|
|
11
11
|
import type { FooterSettings, FooterGroupSettings, SeparatorStyle, IconStyle, ColorMode } from "./types.js";
|
|
12
|
-
import { UNIPI_SETTINGS_KEY } from "@pi-unipi/core";
|
|
12
|
+
import { UNIPI_SETTINGS_KEY, getSettings, registerSettings, setSettings } from "@pi-unipi/core";
|
|
13
|
+
import { getFooterRegistry } from "./registry/index.js";
|
|
13
14
|
|
|
14
15
|
/** Default footer settings */
|
|
15
16
|
export const DEFAULT_FOOTER_SETTINGS: FooterSettings = {
|
|
@@ -80,17 +81,89 @@ function writeSettingsFile(settings: Record<string, unknown>): boolean {
|
|
|
80
81
|
* Load footer settings from settings.json.
|
|
81
82
|
* Falls back to defaults for any missing fields.
|
|
82
83
|
*/
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
84
|
+
// Registered with the unified settings hub; the engine's migration imports
|
|
85
|
+
// the legacy pi-settings unipi.footer block into the canonical layout once.
|
|
86
|
+
// The "Segments…" page resolves sections from the LIVE FooterRegistry at
|
|
87
|
+
// open time — it replaces the deleted legacy footer overlay.
|
|
88
|
+
registerSettings({
|
|
89
|
+
namespace: "footer",
|
|
90
|
+
label: "Footer",
|
|
91
|
+
defaults: DEFAULT_FOOTER_SETTINGS as unknown as Record<string, unknown>,
|
|
92
|
+
schema: [
|
|
93
|
+
{
|
|
94
|
+
title: "General",
|
|
95
|
+
fields: [
|
|
96
|
+
{ key: "enabled", type: "boolean", label: "Footer enabled" },
|
|
97
|
+
{
|
|
98
|
+
key: "preset",
|
|
99
|
+
type: "enum",
|
|
100
|
+
label: "Preset",
|
|
101
|
+
options: ["default", "classic", "minimal", "dense", "devops", "zen"],
|
|
102
|
+
description: "Segment layout preset (glance mode uses its own frame)",
|
|
103
|
+
},
|
|
104
|
+
{ key: "glanceMode", type: "boolean", label: "Glance mode", description: "The input-box frame renderer" },
|
|
105
|
+
{ key: "showFullLabels", type: "boolean", label: "Full labels", description: "Labeled instead of compact segments" },
|
|
106
|
+
{
|
|
107
|
+
key: "separator",
|
|
108
|
+
type: "enum",
|
|
109
|
+
label: "Separator",
|
|
110
|
+
description: "Segment divider style",
|
|
111
|
+
options: ["powerline", "powerline-thin", "slash", "pipe", "dot", "ascii"],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
key: "zoneSeparator",
|
|
115
|
+
type: "enum",
|
|
116
|
+
label: "Zone separator",
|
|
117
|
+
description: "Divider between zones (left · center · right)",
|
|
118
|
+
options: ["│", "╎", "·", "─", "none"],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
key: "iconStyle",
|
|
122
|
+
type: "enum",
|
|
123
|
+
label: "Icon style",
|
|
124
|
+
options: [
|
|
125
|
+
{ value: "emoji", label: "emoji" },
|
|
126
|
+
{ value: "nerd", label: "nerd font" },
|
|
127
|
+
{ value: "text", label: "text only" },
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
key: "colorMode",
|
|
132
|
+
type: "enum",
|
|
133
|
+
label: "Color mode",
|
|
134
|
+
options: ["auto", "truecolor", "256", "mono"],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
title: "Segments",
|
|
140
|
+
fields: [
|
|
141
|
+
{
|
|
142
|
+
key: "segments-page",
|
|
143
|
+
type: "page",
|
|
144
|
+
label: "Segments…",
|
|
145
|
+
description: "Per-group and per-segment visibility",
|
|
146
|
+
sections: () =>
|
|
147
|
+
getFooterRegistry().getAllGroups().map((g) => ({
|
|
148
|
+
title: g.name,
|
|
149
|
+
fields: [
|
|
150
|
+
{ key: `groups.${g.id}.show`, type: "boolean" as const, label: `Show ${g.name}` },
|
|
151
|
+
...g.segments.map((seg) => ({
|
|
152
|
+
key: `groups.${g.id}.segments.${seg.id}`,
|
|
153
|
+
type: "boolean" as const,
|
|
154
|
+
label: seg.label,
|
|
155
|
+
})),
|
|
156
|
+
],
|
|
157
|
+
})),
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
});
|
|
86
163
|
|
|
164
|
+
export function loadFooterSettings(): FooterSettings {
|
|
165
|
+
const footer = getSettings("footer", process.cwd());
|
|
87
166
|
try {
|
|
88
|
-
const unipi = raw[UNIPI_SETTINGS_KEY] as Record<string, unknown> | undefined;
|
|
89
|
-
if (!unipi) return { ...DEFAULT_FOOTER_SETTINGS };
|
|
90
|
-
|
|
91
|
-
const footer = unipi.footer as Record<string, unknown> | undefined;
|
|
92
|
-
if (!footer) return { ...DEFAULT_FOOTER_SETTINGS };
|
|
93
|
-
|
|
94
167
|
return {
|
|
95
168
|
enabled: typeof footer.enabled === "boolean" ? footer.enabled : DEFAULT_FOOTER_SETTINGS.enabled,
|
|
96
169
|
preset: typeof footer.preset === "string" ? footer.preset : DEFAULT_FOOTER_SETTINGS.preset,
|
|
@@ -116,14 +189,12 @@ export function loadFooterSettings(): FooterSettings {
|
|
|
116
189
|
* Merges with existing settings (preserves other keys).
|
|
117
190
|
*/
|
|
118
191
|
export function saveFooterSettings(partial: Partial<FooterSettings>): boolean {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
return writeSettingsFile(raw);
|
|
192
|
+
try {
|
|
193
|
+
setSettings("footer", partial as Record<string, unknown>, "global", process.cwd());
|
|
194
|
+
return true;
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
127
198
|
}
|
|
128
199
|
|
|
129
200
|
/**
|
package/src/events.ts
CHANGED
|
@@ -153,6 +153,50 @@ export function subscribeToEvents(
|
|
|
153
153
|
})
|
|
154
154
|
);
|
|
155
155
|
|
|
156
|
+
// ─── Plan mode / permission mode (workflow) ─────────────────────────────
|
|
157
|
+
|
|
158
|
+
unsubscribers.push(
|
|
159
|
+
pi.events.on(UNIPI_EVENTS.PLAN_MODE_CHANGED, (event: unknown) => {
|
|
160
|
+
try {
|
|
161
|
+
const active = (event as { active?: boolean })?.active === true;
|
|
162
|
+
const existing = registry.getGroupData("core") as Record<string, unknown> | undefined;
|
|
163
|
+
registry.updateData("core", { ...existing, planMode: active });
|
|
164
|
+
} catch {
|
|
165
|
+
// Silently ignore — event handler errors are non-blocking.
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
unsubscribers.push(
|
|
171
|
+
pi.events.on(UNIPI_EVENTS.PERMISSION_MODE_CHANGED, (event: unknown) => {
|
|
172
|
+
try {
|
|
173
|
+
const mode = (event as { mode?: string })?.mode;
|
|
174
|
+
if (typeof mode === "string" && mode.length > 0) {
|
|
175
|
+
const existing = registry.getGroupData("core") as Record<string, unknown> | undefined;
|
|
176
|
+
registry.updateData("core", { ...existing, permissionMode: mode });
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// Silently ignore — event handler errors are non-blocking.
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// ─── Long-horizon events ────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
unsubscribers.push(
|
|
187
|
+
pi.events.on(UNIPI_EVENTS.LONG_HORIZON_MODE_RESOLVED, (event: unknown) => {
|
|
188
|
+
try {
|
|
189
|
+
const mode = (event as { mode?: string })?.mode;
|
|
190
|
+
if (typeof mode === "string" && mode.length > 0) {
|
|
191
|
+
const existing = registry.getGroupData("core") as Record<string, unknown> | undefined;
|
|
192
|
+
registry.updateData("core", { ...existing, lhMode: mode });
|
|
193
|
+
}
|
|
194
|
+
} catch {
|
|
195
|
+
// Silently ignore — event handler errors are non-blocking.
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
);
|
|
199
|
+
|
|
156
200
|
// ─── Ralph events ──────────────────────────────────────────────────────
|
|
157
201
|
|
|
158
202
|
unsubscribers.push(
|
package/src/glance-editor.ts
CHANGED
|
@@ -25,6 +25,12 @@ import { lolcatRainbow, paintLolcatGradient } from "./rendering/lolcat.js";
|
|
|
25
25
|
export interface GlanceStatus {
|
|
26
26
|
/** Workspace/project directory name (bottom-left). */
|
|
27
27
|
workspace: string;
|
|
28
|
+
/** Active long-horizon mode label, beside the brand (null → omitted). */
|
|
29
|
+
lhMode: string | null;
|
|
30
|
+
/** Plan mode is active (top-right PLAN badge). */
|
|
31
|
+
planMode: boolean;
|
|
32
|
+
/** Permission mode (ask | auto | full) shown top-right (null → omitted). */
|
|
33
|
+
permissionMode: string | null;
|
|
28
34
|
/** Git branch for the top title (null → omitted). */
|
|
29
35
|
branch: string | null;
|
|
30
36
|
/** Context usage percent (null when unknown). */
|
|
@@ -56,6 +62,55 @@ const SEP = " \u2502 "; // │
|
|
|
56
62
|
const RESET = "\x1b[0m";
|
|
57
63
|
const LEAD_FG = "\x1b[1m\x1b[38;2;181;189;104m"; // bold accent (matches success tint)
|
|
58
64
|
const DIM_FG = "\x1b[38;2;120;124;134m"; // muted grey
|
|
65
|
+
const WARN_FG = "\x1b[38;2;214;140;60m"; // warning / full-permission
|
|
66
|
+
// PLAN badge: dark text on a warning-coloured block. Targeted-off closes keep
|
|
67
|
+
// the surrounding border span intact.
|
|
68
|
+
const PLAN_BG = "\x1b[1m\x1b[48;2;214;140;60m\x1b[38;2;26;20;14m";
|
|
69
|
+
const PLAN_BG_OFF = "\x1b[49m\x1b[39m\x1b[22m";
|
|
70
|
+
|
|
71
|
+
/** Top-right permission/plan cluster: ` PLAN auto ` (plain text returned too). */
|
|
72
|
+
export function renderTopRightBadges(planMode: boolean, permissionMode: string | null): string {
|
|
73
|
+
const parts: string[] = [];
|
|
74
|
+
if (planMode) parts.push(`${PLAN_BG} PLAN ${PLAN_BG_OFF}`);
|
|
75
|
+
if (permissionMode) {
|
|
76
|
+
const painted =
|
|
77
|
+
permissionMode === "full"
|
|
78
|
+
? `${WARN_FG}${permissionMode}${RESET}`
|
|
79
|
+
: permissionMode === "ask"
|
|
80
|
+
? `${LEAD_FG}${permissionMode}${RESET}`
|
|
81
|
+
: `${DIM_FG}${permissionMode}${RESET}`;
|
|
82
|
+
parts.push(painted);
|
|
83
|
+
}
|
|
84
|
+
return parts.length > 0 ? `${parts.join(" ")} ` : "";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Same cluster without the permission label (narrow-terminal fallback). */
|
|
88
|
+
export function renderTopRightBadgesNoPermission(planMode: boolean): string {
|
|
89
|
+
return planMode ? `${PLAN_BG} PLAN ${PLAN_BG_OFF} ` : "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Top-frame layout: the right cluster plus the rule fill, with the narrow-
|
|
94
|
+
* terminal policy applied (drop the permission label first).
|
|
95
|
+
*/
|
|
96
|
+
export function planTopFrameWidths(
|
|
97
|
+
safe: number,
|
|
98
|
+
plainLeadWidth: number,
|
|
99
|
+
planMode: boolean,
|
|
100
|
+
permissionMode: string | null,
|
|
101
|
+
): { cluster: string; filler: number; permissionDropped: boolean } {
|
|
102
|
+
const fits = (cluster: string): number => safe - 2 - plainLeadWidth - visibleWidth(stripControls(cluster));
|
|
103
|
+
let cluster = renderTopRightBadges(planMode, permissionMode);
|
|
104
|
+
let permissionDropped = false;
|
|
105
|
+
if (cluster.length > 0 && fits(cluster) < 2) {
|
|
106
|
+
const withoutPermission = renderTopRightBadgesNoPermission(planMode);
|
|
107
|
+
if (withoutPermission !== cluster) {
|
|
108
|
+
cluster = withoutPermission;
|
|
109
|
+
permissionDropped = true;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { cluster, filler: Math.max(2, fits(cluster)), permissionDropped };
|
|
113
|
+
}
|
|
59
114
|
|
|
60
115
|
type FusionDisplay = NonNullable<GlanceStatus["fusion"]>;
|
|
61
116
|
|
|
@@ -140,15 +195,18 @@ export function composeGlanceTitles(
|
|
|
140
195
|
brand: string,
|
|
141
196
|
branch: string | null,
|
|
142
197
|
workspace: string,
|
|
198
|
+
lhMode: string | null = null,
|
|
143
199
|
): { titleParts: string[]; leftTitle: string } {
|
|
144
200
|
const titleParts: string[] = [];
|
|
145
201
|
if (getResolvedIconStyle() === "text") {
|
|
146
202
|
titleParts.push(brand);
|
|
203
|
+
if (lhMode) titleParts.push(`mode:${lhMode}`);
|
|
147
204
|
if (branch) titleParts.push(`branch:${branch}`);
|
|
148
205
|
return { titleParts, leftTitle: ` workspace:${workspace} ` };
|
|
149
206
|
}
|
|
150
207
|
const brandIcon = getIcon("model");
|
|
151
208
|
titleParts.push(`${brandIcon ? brandIcon + " " : ""}${brand}`);
|
|
209
|
+
if (lhMode) titleParts.push(lhMode);
|
|
152
210
|
if (branch) {
|
|
153
211
|
const gitIcon = getIcon("git");
|
|
154
212
|
titleParts.push(`${gitIcon ? gitIcon + " " : ""}${branch}`);
|
|
@@ -218,19 +276,24 @@ export class GlanceEditor extends CustomEditor {
|
|
|
218
276
|
// Brand rendered as an animated lolcat gradient (phase from wall time;
|
|
219
277
|
// the footer's 1s refresh timer re-renders, so it shimmers each tick).
|
|
220
278
|
const brand = lolcatRainbow("UNIPI", Date.now() / 1000);
|
|
221
|
-
const { titleParts, leftTitle } = composeGlanceTitles(brand, st.branch, st.workspace);
|
|
279
|
+
const { titleParts, leftTitle } = composeGlanceTitles(brand, st.branch, st.workspace, st.lhMode);
|
|
222
280
|
const title = titleParts.join(SEP);
|
|
223
281
|
const leadRule = `${BORDER.horizontal} `;
|
|
224
282
|
const titleText = ` ${title}${SEP}`;
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
283
|
+
// Right-aligned PLAN/permission cluster. Narrow terminals drop the
|
|
284
|
+
// permission label first; the final truncateToWidth trims the rest.
|
|
285
|
+
const plainLead = visibleWidth(stripControls(leadRule + titleText));
|
|
286
|
+
const { cluster: topCluster, filler: topFiller } = planTopFrameWidths(
|
|
287
|
+
safe,
|
|
288
|
+
plainLead,
|
|
289
|
+
st.planMode,
|
|
290
|
+
st.permissionMode,
|
|
229
291
|
);
|
|
230
292
|
const top =
|
|
231
293
|
border(BORDER.topLeft) +
|
|
232
294
|
border(leadRule + titleText) +
|
|
233
295
|
border(repeat(BORDER.horizontal, topFiller)) +
|
|
296
|
+
topCluster +
|
|
234
297
|
border(BORDER.topRight);
|
|
235
298
|
|
|
236
299
|
// ── Body rows: │ content │ ──
|
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 } from "@pi-unipi/core";
|
|
10
|
+
import { UNIPI_EVENTS, emitEvent, UNIPI_PREFIX, FOOTER_COMMANDS, getSharedFusionStatus, 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";
|
|
@@ -30,6 +30,7 @@ import { STATUS_EXT_SEGMENTS } from "./segments/status-ext.js";
|
|
|
30
30
|
import type { FooterGroup, FooterSegment } from "./types.js";
|
|
31
31
|
import { tpsTracker } from "./tps-tracker.js";
|
|
32
32
|
import { renderProcessLine } from "./process-line.js";
|
|
33
|
+
import { MODE_LABELS } from "./segments/long-horizon.js";
|
|
33
34
|
|
|
34
35
|
/** All segment groups */
|
|
35
36
|
const ALL_GROUPS: FooterGroup[] = [
|
|
@@ -191,7 +192,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
191
192
|
|
|
192
193
|
// ─── Register commands ──────────────────────────────────────────────────
|
|
193
194
|
|
|
194
|
-
registerCommands(pi, state
|
|
195
|
+
registerCommands(pi, state);
|
|
195
196
|
|
|
196
197
|
// ─── Emit MODULE_READY ──────────────────────────────────────────────────
|
|
197
198
|
|
|
@@ -199,7 +200,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
199
200
|
emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
|
|
200
201
|
name: "@pi-unipi/footer",
|
|
201
202
|
version: "0.1.0",
|
|
202
|
-
commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.
|
|
203
|
+
commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`],
|
|
203
204
|
tools: [],
|
|
204
205
|
});
|
|
205
206
|
});
|
|
@@ -440,8 +441,29 @@ function installGlanceEditor(
|
|
|
440
441
|
if (modelName.startsWith("Claude ")) modelName = modelName.slice(7);
|
|
441
442
|
const branch = (st.footerData as any)?.getGitBranch?.() ?? null;
|
|
442
443
|
const fusion = getSharedFusionStatus() ?? null;
|
|
444
|
+
// Pull the mode from the shared holder (set on both resume and live
|
|
445
|
+
// turns); fall back to the registry data the event listener writes.
|
|
446
|
+
const lhModeRaw = getSharedLongHorizonMode()
|
|
447
|
+
?? (st.registry.getGroupData("core") as { lhMode?: string } | undefined)?.lhMode;
|
|
448
|
+
const lhMode = typeof lhModeRaw === "string" && lhModeRaw.length > 0
|
|
449
|
+
? MODE_LABELS[lhModeRaw] ?? lhModeRaw
|
|
450
|
+
: null;
|
|
451
|
+
// Plan/permission state rides the same CORE registry data, written by
|
|
452
|
+
// the workflow module's events (also re-emitted on session_start).
|
|
453
|
+
const coreData = st.registry.getGroupData("core") as
|
|
454
|
+
| { planMode?: boolean; permissionMode?: string }
|
|
455
|
+
| undefined;
|
|
456
|
+
const sharedBadges = getSharedPlanPermissionStatus();
|
|
443
457
|
return {
|
|
444
458
|
workspace,
|
|
459
|
+
lhMode,
|
|
460
|
+
planMode: sharedBadges.planMode || coreData?.planMode === true,
|
|
461
|
+
permissionMode:
|
|
462
|
+
typeof sharedBadges.permissionMode === "string" && sharedBadges.permissionMode.length > 0
|
|
463
|
+
? sharedBadges.permissionMode
|
|
464
|
+
: typeof coreData?.permissionMode === "string" && coreData.permissionMode.length > 0
|
|
465
|
+
? coreData.permissionMode
|
|
466
|
+
: null,
|
|
445
467
|
branch: typeof branch === "string" ? branch : null,
|
|
446
468
|
contextPct: typeof usage?.percent === "number" ? usage.percent : null,
|
|
447
469
|
contextWindow: typeof usage?.contextWindow === "number" ? usage.contextWindow : 0,
|
package/src/presets.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { getDefaultColors } from "./rendering/theme.js";
|
|
|
15
15
|
/** Default preset — glance-style status line + branded head */
|
|
16
16
|
const DEFAULT_PRESET: PresetDef = {
|
|
17
17
|
leftSegments: [
|
|
18
|
-
"uni", "model", "thinking_level", "directory", "git",
|
|
18
|
+
"uni", "lh_mode", "model", "thinking_level", "directory", "git",
|
|
19
19
|
],
|
|
20
20
|
rightSegments: [
|
|
21
21
|
"context_pct", "tokens_total",
|
|
@@ -29,7 +29,7 @@ const DEFAULT_PRESET: PresetDef = {
|
|
|
29
29
|
/** Classic preset — the pre-v3 balanced view (kept for /unipi:footer) */
|
|
30
30
|
const CLASSIC_PRESET: PresetDef = {
|
|
31
31
|
leftSegments: [
|
|
32
|
-
"model", "api_state", "tool_count", "git",
|
|
32
|
+
"lh_mode", "model", "api_state", "tool_count", "git",
|
|
33
33
|
],
|
|
34
34
|
rightSegments: [
|
|
35
35
|
"tps", "context_pct", "cost",
|
|
@@ -46,7 +46,7 @@ const CLASSIC_PRESET: PresetDef = {
|
|
|
46
46
|
/** Minimal preset — just the essentials */
|
|
47
47
|
const MINIMAL_PRESET: PresetDef = {
|
|
48
48
|
leftSegments: [
|
|
49
|
-
"model", "git",
|
|
49
|
+
"lh_mode", "model", "git",
|
|
50
50
|
],
|
|
51
51
|
rightSegments: [
|
|
52
52
|
"context_pct",
|
|
@@ -59,7 +59,7 @@ const MINIMAL_PRESET: PresetDef = {
|
|
|
59
59
|
/** Compact preset — core + key stats */
|
|
60
60
|
const COMPACT_PRESET: PresetDef = {
|
|
61
61
|
leftSegments: [
|
|
62
|
-
"model", "git",
|
|
62
|
+
"lh_mode", "model", "git",
|
|
63
63
|
],
|
|
64
64
|
rightSegments: [
|
|
65
65
|
"tps", "context_pct", "cost",
|
|
@@ -72,7 +72,7 @@ const COMPACT_PRESET: PresetDef = {
|
|
|
72
72
|
/** Full preset — everything */
|
|
73
73
|
const FULL_PRESET: PresetDef = {
|
|
74
74
|
leftSegments: [
|
|
75
|
-
"model", "api_state", "tool_count", "git", "current_command", "session",
|
|
75
|
+
"lh_mode", "model", "api_state", "tool_count", "git", "current_command", "session",
|
|
76
76
|
],
|
|
77
77
|
rightSegments: [
|
|
78
78
|
"tps", "context_pct", "cost", "tokens_total",
|
|
@@ -97,7 +97,7 @@ const FULL_PRESET: PresetDef = {
|
|
|
97
97
|
/** ASCII preset — safe for any terminal */
|
|
98
98
|
const ASCII_PRESET: PresetDef = {
|
|
99
99
|
leftSegments: [
|
|
100
|
-
"model", "git",
|
|
100
|
+
"lh_mode", "model", "git",
|
|
101
101
|
],
|
|
102
102
|
rightSegments: [
|
|
103
103
|
"tps", "context_pct", "cost",
|
package/src/segments/core.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { FooterSegment, FooterSegmentContext, RenderedSegment, SemanticColo
|
|
|
11
11
|
import { applyColor } from "../rendering/theme.js";
|
|
12
12
|
import { getIcon } from "../rendering/icons.js";
|
|
13
13
|
import { tpsTracker } from "../tps-tracker.js";
|
|
14
|
+
import { LONG_HORIZON_SEGMENTS } from "./long-horizon.js";
|
|
14
15
|
|
|
15
16
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -403,6 +404,7 @@ export const CORE_SEGMENTS: FooterSegment[] = [
|
|
|
403
404
|
{ id: "session", label: "Session", shortLabel: "SES", description: "Session identifier", zone: "left", render: renderSessionSegment, defaultShow: false },
|
|
404
405
|
{ id: "hostname", label: "Hostname", shortLabel: "HST", description: "Machine hostname", zone: "left", render: renderHostnameSegment, defaultShow: false },
|
|
405
406
|
{ id: "uni", label: "Unipi", shortLabel: "UNI", description: "Unipi brand mark", zone: "left", render: renderUniBrandSegment, defaultShow: true },
|
|
407
|
+
...LONG_HORIZON_SEGMENTS,
|
|
406
408
|
{ id: "directory", label: "Directory", shortLabel: "DIR", description: "Current directory name", zone: "left", render: renderDirectorySegment, defaultShow: true },
|
|
407
409
|
{ id: "clock", label: "Clock", shortLabel: "CLK", description: "Current wall time (HH:MM:SS)", zone: "right", render: renderClockSegment, defaultShow: true },
|
|
408
410
|
{ id: "duration", label: "Duration", shortLabel: "DUR", description: "Session duration", zone: "right", render: renderDurationSegment, defaultShow: true },
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/footer — Long-horizon mode segment
|
|
3
|
+
*
|
|
4
|
+
* Renders the active long-horizon mode right beside the UNIPI brand mark:
|
|
5
|
+
* "Goal Mode" · "Ralph Mode" · "Swarm Mode" · "Graph Mode" · "Regular Mode".
|
|
6
|
+
*
|
|
7
|
+
* The segment lives in the CORE group (for placement) but reads a module
|
|
8
|
+
* store fed by LONG_HORIZON_MODE_RESOLVED events — group-data binding would
|
|
9
|
+
* require it to live in its own group, which lands it after the whole core
|
|
10
|
+
* block. Hidden until the first mode event arrives, so sessions without
|
|
11
|
+
* long-horizon installed show nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
|
|
15
|
+
import { applyColor } from "../rendering/theme.js";
|
|
16
|
+
|
|
17
|
+
export const MODE_LABELS: Record<string, string> = {
|
|
18
|
+
goal: "Goal Mode",
|
|
19
|
+
ralph: "Ralph Mode",
|
|
20
|
+
swarm: "Swarm Mode",
|
|
21
|
+
graph: "Graph Mode",
|
|
22
|
+
none: "Regular Mode",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Mode rides the CORE group's data ({ lhMode }) via registry.updateData —
|
|
27
|
+
* the data-change notification is what triggers a footer re-render (a
|
|
28
|
+
* module store alone never invalidates).
|
|
29
|
+
*/
|
|
30
|
+
function renderModeSegment(ctx: FooterSegmentContext): RenderedSegment {
|
|
31
|
+
const mode = (ctx.data as { lhMode?: string } | undefined)?.lhMode;
|
|
32
|
+
if (typeof mode !== "string" || mode.length === 0) return { content: "", visible: false };
|
|
33
|
+
const label = MODE_LABELS[mode] ?? mode;
|
|
34
|
+
return { content: applyColor("model", label, ctx.theme, ctx.colors), visible: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const LONG_HORIZON_SEGMENTS: FooterSegment[] = [
|
|
38
|
+
{
|
|
39
|
+
id: "lh_mode",
|
|
40
|
+
label: "Mode",
|
|
41
|
+
shortLabel: "MODE",
|
|
42
|
+
description: "Active long-horizon mode (Goal/Ralph/Swarm/Graph/Regular)",
|
|
43
|
+
zone: "left",
|
|
44
|
+
render: renderModeSegment,
|
|
45
|
+
defaultShow: true,
|
|
46
|
+
},
|
|
47
|
+
];
|
package/src/segments/memory.ts
CHANGED
|
@@ -35,6 +35,9 @@ interface InfoMemoryData {
|
|
|
35
35
|
projectCount?: { value: string };
|
|
36
36
|
totalCount?: { value: string };
|
|
37
37
|
consolidations?: { value: string };
|
|
38
|
+
recall?: { value: string };
|
|
39
|
+
write?: { value: string };
|
|
40
|
+
pending?: { value: string };
|
|
38
41
|
[key: string]: unknown;
|
|
39
42
|
}
|
|
40
43
|
|
|
@@ -138,8 +141,28 @@ function renderConsolidationsSegment(ctx: FooterSegmentContext): RenderedSegment
|
|
|
138
141
|
return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
|
|
139
142
|
}
|
|
140
143
|
|
|
144
|
+
/** recall/write state + pending count, from the memory info group's dataProvider. */
|
|
145
|
+
function renderMemoryStateSegment(ctx: FooterSegmentContext): RenderedSegment {
|
|
146
|
+
const infoData = getInfoRegistryMemoryData();
|
|
147
|
+
const recall = infoData?.recall?.value;
|
|
148
|
+
const write = infoData?.write?.value;
|
|
149
|
+
const pending = infoData?.pending?.value;
|
|
150
|
+
if (recall === undefined && write === undefined && pending === undefined) {
|
|
151
|
+
return { content: "", visible: false };
|
|
152
|
+
}
|
|
153
|
+
const parts = [
|
|
154
|
+
recall !== undefined ? `r:${recall}` : null,
|
|
155
|
+
write !== undefined ? `w:${write}` : null,
|
|
156
|
+
pending !== undefined && pending !== "0" ? `+${pending}⧗` : null,
|
|
157
|
+
].filter(Boolean).join(" ");
|
|
158
|
+
if (!parts) return { content: "", visible: false };
|
|
159
|
+
const content = withIcon("memoryState", parts);
|
|
160
|
+
return { content: applyColor("memory", content, ctx.theme, ctx.colors), visible: true };
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
export const MEMORY_SEGMENTS: FooterSegment[] = [
|
|
142
164
|
{ id: "project_count", label: "Project Memory", shortLabel: "MEM", description: "Memory entries for this project", zone: "center", render: renderProjectCountSegment, defaultShow: true },
|
|
143
165
|
{ id: "total_count", label: "Total Memory", shortLabel: "TOT", description: "Total memory entries across projects", zone: "center", render: renderTotalCountSegment, defaultShow: true },
|
|
166
|
+
{ id: "memory_state", label: "Memory State", shortLabel: "MST", description: "Recall/write switches + pending ops", zone: "center", render: renderMemoryStateSegment, defaultShow: true },
|
|
144
167
|
{ id: "consolidations", label: "Consolidations", shortLabel: "CNS", description: "Number of memory consolidations", zone: "center", render: renderConsolidationsSegment, defaultShow: false },
|
|
145
168
|
];
|
package/src/tui/settings-tui.ts
DELETED
|
@@ -1,503 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @pi-unipi/footer — Settings TUI
|
|
3
|
-
*
|
|
4
|
-
* Unified settings overlay with 3 categories:
|
|
5
|
-
* - Appearance: preset, separator, icon style, full labels
|
|
6
|
-
* - Segments: group → segment drill-down
|
|
7
|
-
* - Labels & Help: label mode, zone headers
|
|
8
|
-
*
|
|
9
|
-
* Uses pi-tui SettingsList for vim/arrow keybinding support.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
-
import { Key, matchesKey, SettingsList, type SettingItem, type SettingsListTheme } from "@earendil-works/pi-tui";
|
|
14
|
-
import { loadFooterSettings, saveFooterSettings } from "../config.js";
|
|
15
|
-
import { PRESET_NAMES } from "../presets.js";
|
|
16
|
-
import { setIconStyle } from "../rendering/icons.js";
|
|
17
|
-
import type { FooterGroup, FooterSettings, SeparatorStyle, IconStyle } from "../types.js";
|
|
18
|
-
import { boxInnerWidth, OverlayTheme } from "@pi-unipi/core";
|
|
19
|
-
|
|
20
|
-
// ─── Section types ─────────────────────────────────────────────────────
|
|
21
|
-
|
|
22
|
-
type Section = "appearance" | "segments" | "labels";
|
|
23
|
-
const SECTIONS: Section[] = ["appearance", "segments", "labels"];
|
|
24
|
-
const SECTION_LABELS: Record<Section, string> = {
|
|
25
|
-
appearance: "Appearance",
|
|
26
|
-
segments: "Segments",
|
|
27
|
-
labels: "Labels & Help",
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// ─── Valid option values ───────────────────────────────────────────────
|
|
31
|
-
|
|
32
|
-
const SEPARATOR_STYLES: SeparatorStyle[] = ["powerline", "powerline-thin", "slash", "pipe", "dot", "ascii"];
|
|
33
|
-
const ICON_STYLES: IconStyle[] = ["nerd", "emoji", "text"];
|
|
34
|
-
const ZONE_SEPARATOR_OPTIONS = ["│", "╎", "·", "─", "none"];
|
|
35
|
-
|
|
36
|
-
// ─── Theme for SettingsList ────────────────────────────────────────────
|
|
37
|
-
|
|
38
|
-
const THEME: SettingsListTheme = {
|
|
39
|
-
label: (text, selected) => selected ? `\x1b[1m${text}\x1b[0m` : `\x1b[2m${text}\x1b[0m`,
|
|
40
|
-
value: (text, selected) => selected ? `\x1b[35m${text}\x1b[0m` : `\x1b[35m${text}\x1b[0m`,
|
|
41
|
-
description: (text) => `\x1b[90m${text}\x1b[0m`,
|
|
42
|
-
cursor: `\x1b[36m▸\x1b[0m`,
|
|
43
|
-
hint: (text) => `\x1b[2m${text}\x1b[0m`,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// ─── Shared box-drawing helper ────────────────────────────────────────
|
|
47
|
-
|
|
48
|
-
const overlay = new OverlayTheme();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// ─── Show the footer settings overlay ──────────────────────────────────
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Returns the overlay's promise so callers can run post-close actions
|
|
55
|
-
* (e.g. the glance editor swap) AFTER focus has returned to the editor —
|
|
56
|
-
* setEditorComponent() steals keyboard focus and would strand this very
|
|
57
|
-
* overlay unclosable if invoked from onSettingsChanged while it is open.
|
|
58
|
-
*/
|
|
59
|
-
export function showFooterSettings(ctx: ExtensionCommandContext, groups: FooterGroup[], onSettingsChanged?: () => void): Promise<void> {
|
|
60
|
-
return ctx.ui.custom<void>(
|
|
61
|
-
(tui, _theme, _keybindings, done) => {
|
|
62
|
-
const overlay = new FooterSettingsOverlay(groups, onSettingsChanged);
|
|
63
|
-
overlay.onClose = () => done();
|
|
64
|
-
|
|
65
|
-
return {
|
|
66
|
-
focused: true,
|
|
67
|
-
invalidate: () => overlay.invalidate(),
|
|
68
|
-
render: (width: number) => overlay.render(width),
|
|
69
|
-
handleInput: (data: string) => {
|
|
70
|
-
overlay.handleInput(data);
|
|
71
|
-
tui.requestRender();
|
|
72
|
-
},
|
|
73
|
-
dispose: () => {},
|
|
74
|
-
};
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
overlay: true,
|
|
78
|
-
overlayOptions: () => ({
|
|
79
|
-
anchor: "center" as const,
|
|
80
|
-
}),
|
|
81
|
-
},
|
|
82
|
-
).catch(() => {
|
|
83
|
-
// Silently ignore — overlay errors are non-blocking.
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// ─── Footer settings overlay component ─────────────────────────────────
|
|
88
|
-
|
|
89
|
-
class FooterSettingsOverlay {
|
|
90
|
-
private settings: FooterSettings;
|
|
91
|
-
private groups: FooterGroup[];
|
|
92
|
-
private section: Section = "appearance";
|
|
93
|
-
private selectedGroupId: string | null = null;
|
|
94
|
-
onClose?: () => void;
|
|
95
|
-
private onSettingsChanged?: () => void;
|
|
96
|
-
|
|
97
|
-
// Per-section SettingsList instances
|
|
98
|
-
private appearanceList!: SettingsList;
|
|
99
|
-
private groupList!: SettingsList;
|
|
100
|
-
private segmentList: SettingsList | null = null;
|
|
101
|
-
private labelsList!: SettingsList;
|
|
102
|
-
|
|
103
|
-
constructor(groups: FooterGroup[], onSettingsChanged?: () => void) {
|
|
104
|
-
this.settings = loadFooterSettings();
|
|
105
|
-
this.groups = groups;
|
|
106
|
-
this.onSettingsChanged = onSettingsChanged;
|
|
107
|
-
this.buildAppearanceList();
|
|
108
|
-
this.buildGroupList();
|
|
109
|
-
this.buildLabelsList();
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
invalidate(): void {
|
|
113
|
-
this.appearanceList?.invalidate();
|
|
114
|
-
this.groupList?.invalidate();
|
|
115
|
-
this.segmentList?.invalidate();
|
|
116
|
-
this.labelsList?.invalidate();
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
handleInput(data: string): void {
|
|
120
|
-
// Tab cycles sections
|
|
121
|
-
if (data === "\t" || matchesKey(data, Key.shift("tab"))) {
|
|
122
|
-
const idx = SECTIONS.indexOf(this.section);
|
|
123
|
-
if (data === "\t") {
|
|
124
|
-
this.section = SECTIONS[(idx + 1) % SECTIONS.length];
|
|
125
|
-
} else {
|
|
126
|
-
this.section = SECTIONS[(idx - 1 + SECTIONS.length) % SECTIONS.length];
|
|
127
|
-
}
|
|
128
|
-
// If leaving segments drill-down, go back to groups
|
|
129
|
-
if (this.section !== "segments") {
|
|
130
|
-
this.selectedGroupId = null;
|
|
131
|
-
this.segmentList = null;
|
|
132
|
-
}
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// q — always close
|
|
137
|
-
if (data === "q") {
|
|
138
|
-
this.onClose?.();
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Escape — back from drill-down, or close at root
|
|
143
|
-
if (matchesKey(data, "escape")) {
|
|
144
|
-
if (this.section === "segments" && this.selectedGroupId) {
|
|
145
|
-
this.backToGroups();
|
|
146
|
-
} else {
|
|
147
|
-
this.onClose?.();
|
|
148
|
-
}
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// j — navigate down (same as down arrow)
|
|
153
|
-
if (data === "j") {
|
|
154
|
-
this.currentList?.handleInput("\x1b[B");
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// k — navigate up (same as up arrow)
|
|
159
|
-
if (data === "k") {
|
|
160
|
-
this.currentList?.handleInput("\x1b[A");
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// l — drill into group from segments section (same as Enter)
|
|
165
|
-
if (data === "l" && this.section === "segments" && !this.selectedGroupId) {
|
|
166
|
-
const focusedId = this.getFocusedGroupId();
|
|
167
|
-
if (focusedId) {
|
|
168
|
-
this.enterSegmentsMode(focusedId);
|
|
169
|
-
}
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Space — toggle on/off
|
|
174
|
-
if (data === " ") {
|
|
175
|
-
this.currentList?.handleInput("\r");
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// h — back from segment drill-down
|
|
180
|
-
if (data === "h" && this.section === "segments" && this.selectedGroupId) {
|
|
181
|
-
this.backToGroups();
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// / — focus search in segments section
|
|
186
|
-
if (data === "/") {
|
|
187
|
-
this.currentList?.handleInput("/");
|
|
188
|
-
return;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Enter in segments/groups mode — enter segments for the focused group
|
|
192
|
-
if (data === "\r" && this.section === "segments" && !this.selectedGroupId) {
|
|
193
|
-
const focusedId = this.getFocusedGroupId();
|
|
194
|
-
if (focusedId) {
|
|
195
|
-
this.enterSegmentsMode(focusedId);
|
|
196
|
-
}
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Left arrow / backspace in segment drill-down — back to groups
|
|
201
|
-
if (this.section === "segments" && this.selectedGroupId && (matchesKey(data, Key.left) || data === "\x7f")) {
|
|
202
|
-
this.backToGroups();
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Delegate to current SettingsList
|
|
207
|
-
this.currentList?.handleInput(data);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
private get currentList(): SettingsList | null {
|
|
211
|
-
switch (this.section) {
|
|
212
|
-
case "appearance": return this.appearanceList;
|
|
213
|
-
case "segments":
|
|
214
|
-
return this.segmentList ?? this.groupList;
|
|
215
|
-
case "labels": return this.labelsList;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// ─── Build SettingsList instances ──────────────────────────────────
|
|
220
|
-
|
|
221
|
-
private buildAppearanceList(): void {
|
|
222
|
-
const items: SettingItem[] = [
|
|
223
|
-
{
|
|
224
|
-
id: "glanceMode",
|
|
225
|
-
label: "Glance Footer",
|
|
226
|
-
description: "Experimental: framed input box + session strip (replaces classic row)",
|
|
227
|
-
currentValue: this.settings.glanceMode !== false ? "on" : "off",
|
|
228
|
-
values: ["on", "off"],
|
|
229
|
-
},
|
|
230
|
-
{
|
|
231
|
-
id: "preset",
|
|
232
|
-
label: "Preset",
|
|
233
|
-
description: "Footer layout preset",
|
|
234
|
-
currentValue: this.settings.preset,
|
|
235
|
-
values: PRESET_NAMES,
|
|
236
|
-
},
|
|
237
|
-
{
|
|
238
|
-
id: "separator",
|
|
239
|
-
label: "Separator",
|
|
240
|
-
description: "Segment divider style",
|
|
241
|
-
currentValue: this.settings.separator,
|
|
242
|
-
values: SEPARATOR_STYLES,
|
|
243
|
-
},
|
|
244
|
-
{
|
|
245
|
-
id: "iconStyle",
|
|
246
|
-
label: "Icon Style",
|
|
247
|
-
description: "emoji · nerd (Nerd Font) · text (glance: branch:x, workspace:y)",
|
|
248
|
-
currentValue: this.settings.iconStyle,
|
|
249
|
-
values: ICON_STYLES,
|
|
250
|
-
},
|
|
251
|
-
{
|
|
252
|
-
id: "zoneSeparator",
|
|
253
|
-
label: "Zone Separator",
|
|
254
|
-
description: "Divider between zones (left · center · right)",
|
|
255
|
-
currentValue: this.settings.zoneSeparator ?? "│",
|
|
256
|
-
values: ZONE_SEPARATOR_OPTIONS,
|
|
257
|
-
},
|
|
258
|
-
{
|
|
259
|
-
id: "showFullLabels",
|
|
260
|
-
label: "Full Labels",
|
|
261
|
-
description: "Show descriptive labels instead of abbreviations",
|
|
262
|
-
currentValue: this.settings.showFullLabels ? "on" : "off",
|
|
263
|
-
values: ["on", "off"],
|
|
264
|
-
},
|
|
265
|
-
];
|
|
266
|
-
|
|
267
|
-
this.appearanceList = new SettingsList(
|
|
268
|
-
items,
|
|
269
|
-
Math.min(items.length + 2, 12),
|
|
270
|
-
THEME,
|
|
271
|
-
(id, newValue) => this.onAppearanceChange(id, newValue),
|
|
272
|
-
() => this.onClose?.(),
|
|
273
|
-
{ enableSearch: false },
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
private buildGroupList(): void {
|
|
278
|
-
const items: SettingItem[] = this.groups.map((group) => {
|
|
279
|
-
const groupSettings = this.settings.groups[group.id] ?? { show: group.defaultShow, segments: {} };
|
|
280
|
-
const isEnabled = groupSettings.show;
|
|
281
|
-
const segCount = group.segments.length;
|
|
282
|
-
const enabledCount = group.segments.filter(s => {
|
|
283
|
-
const segOverride = groupSettings.segments?.[s.id];
|
|
284
|
-
return segOverride !== undefined ? segOverride : s.defaultShow;
|
|
285
|
-
}).length;
|
|
286
|
-
|
|
287
|
-
return {
|
|
288
|
-
id: group.id,
|
|
289
|
-
label: group.name,
|
|
290
|
-
description: `${enabledCount}/${segCount} segments · Enter to drill down`,
|
|
291
|
-
currentValue: isEnabled ? "on" : "off",
|
|
292
|
-
values: ["on", "off"],
|
|
293
|
-
};
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
this.groupList = new SettingsList(
|
|
297
|
-
items,
|
|
298
|
-
Math.min(items.length + 2, 15),
|
|
299
|
-
THEME,
|
|
300
|
-
(id, newValue) => this.onGroupChange(id, newValue),
|
|
301
|
-
() => this.onClose?.(),
|
|
302
|
-
{ enableSearch: true },
|
|
303
|
-
);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
private buildSegmentList(groupId: string): void {
|
|
307
|
-
const group = this.groups.find(g => g.id === groupId);
|
|
308
|
-
if (!group) {
|
|
309
|
-
this.segmentList = null;
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const groupSettings = this.settings.groups[group.id] ?? { show: group.defaultShow, segments: {} };
|
|
314
|
-
|
|
315
|
-
const items: SettingItem[] = group.segments.map((seg) => {
|
|
316
|
-
const isEnabled = groupSettings.segments?.[seg.id] ?? seg.defaultShow;
|
|
317
|
-
return {
|
|
318
|
-
id: seg.id,
|
|
319
|
-
label: `${seg.shortLabel} ${seg.label}`,
|
|
320
|
-
description: seg.description,
|
|
321
|
-
currentValue: isEnabled ? "on" : "off",
|
|
322
|
-
values: ["on", "off"],
|
|
323
|
-
};
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
this.segmentList = new SettingsList(
|
|
327
|
-
items,
|
|
328
|
-
Math.min(items.length + 2, 15),
|
|
329
|
-
THEME,
|
|
330
|
-
(id, newValue) => this.onSegmentChange(groupId, id, newValue),
|
|
331
|
-
() => this.backToGroups(),
|
|
332
|
-
{ enableSearch: true },
|
|
333
|
-
);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
private buildLabelsList(): void {
|
|
337
|
-
const items: SettingItem[] = [
|
|
338
|
-
{
|
|
339
|
-
id: "showFullLabelsAlways",
|
|
340
|
-
label: "Full Labels",
|
|
341
|
-
description: "Always show descriptive labels instead of abbreviations",
|
|
342
|
-
currentValue: this.settings.showFullLabels ? "on" : "off",
|
|
343
|
-
values: ["on", "off"],
|
|
344
|
-
},
|
|
345
|
-
{
|
|
346
|
-
id: "showZoneHeaders",
|
|
347
|
-
label: "Zone Headers",
|
|
348
|
-
description: "Show zone labels (Identity / Metrics / Time)",
|
|
349
|
-
currentValue: "off", // TODO: implement zone headers
|
|
350
|
-
values: ["on", "off"],
|
|
351
|
-
},
|
|
352
|
-
];
|
|
353
|
-
|
|
354
|
-
this.labelsList = new SettingsList(
|
|
355
|
-
items,
|
|
356
|
-
Math.min(items.length + 2, 12),
|
|
357
|
-
THEME,
|
|
358
|
-
(id, newValue) => this.onLabelsChange(id, newValue),
|
|
359
|
-
() => this.onClose?.(),
|
|
360
|
-
{ enableSearch: false },
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// ─── Change handlers ───────────────────────────────────────────────
|
|
365
|
-
|
|
366
|
-
private onAppearanceChange(id: string, newValue: string): void {
|
|
367
|
-
switch (id) {
|
|
368
|
-
case "glanceMode":
|
|
369
|
-
this.settings.glanceMode = newValue === "on";
|
|
370
|
-
break;
|
|
371
|
-
case "preset":
|
|
372
|
-
this.settings.preset = newValue;
|
|
373
|
-
break;
|
|
374
|
-
case "separator":
|
|
375
|
-
this.settings.separator = newValue as SeparatorStyle;
|
|
376
|
-
break;
|
|
377
|
-
case "iconStyle":
|
|
378
|
-
this.settings.iconStyle = newValue as IconStyle;
|
|
379
|
-
setIconStyle(newValue as IconStyle);
|
|
380
|
-
break;
|
|
381
|
-
case "zoneSeparator":
|
|
382
|
-
this.settings.zoneSeparator = newValue;
|
|
383
|
-
break;
|
|
384
|
-
case "showFullLabels":
|
|
385
|
-
this.settings.showFullLabels = newValue === "on";
|
|
386
|
-
// Sync with labels section
|
|
387
|
-
this.labelsList.updateValue("showFullLabelsAlways", newValue);
|
|
388
|
-
break;
|
|
389
|
-
}
|
|
390
|
-
saveFooterSettings(this.settings);
|
|
391
|
-
this.appearanceList.updateValue(id, newValue);
|
|
392
|
-
this.onSettingsChanged?.();
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
private onGroupChange(groupId: string, newValue: string): void {
|
|
396
|
-
const groupSettings = this.settings.groups[groupId] ?? { show: true, segments: {} };
|
|
397
|
-
groupSettings.show = newValue === "on";
|
|
398
|
-
this.settings.groups[groupId] = groupSettings;
|
|
399
|
-
saveFooterSettings(this.settings);
|
|
400
|
-
this.groupList.updateValue(groupId, newValue);
|
|
401
|
-
this.onSettingsChanged?.();
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
private onSegmentChange(groupId: string, segmentId: string, newValue: string): void {
|
|
405
|
-
const groupSettings = this.settings.groups[groupId] ?? { show: true, segments: {} };
|
|
406
|
-
if (!groupSettings.segments) groupSettings.segments = {};
|
|
407
|
-
groupSettings.segments[segmentId] = newValue === "on";
|
|
408
|
-
this.settings.groups[groupId] = groupSettings;
|
|
409
|
-
saveFooterSettings(this.settings);
|
|
410
|
-
this.segmentList?.updateValue(segmentId, newValue);
|
|
411
|
-
this.onSettingsChanged?.();
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
private onLabelsChange(id: string, newValue: string): void {
|
|
415
|
-
switch (id) {
|
|
416
|
-
case "showFullLabelsAlways":
|
|
417
|
-
this.settings.showFullLabels = newValue === "on";
|
|
418
|
-
// Sync with appearance section
|
|
419
|
-
this.appearanceList.updateValue("showFullLabels", newValue);
|
|
420
|
-
break;
|
|
421
|
-
case "showZoneHeaders":
|
|
422
|
-
// TODO: implement zone headers setting
|
|
423
|
-
break;
|
|
424
|
-
}
|
|
425
|
-
saveFooterSettings(this.settings);
|
|
426
|
-
this.labelsList.updateValue(id, newValue);
|
|
427
|
-
this.onSettingsChanged?.();
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// ─── Section navigation ────────────────────────────────────────────
|
|
431
|
-
|
|
432
|
-
private getFocusedGroupId(): string | null {
|
|
433
|
-
if (this.selectedGroupId) return this.selectedGroupId;
|
|
434
|
-
// Access SettingsList internal state — it doesn't expose a getSelectedId() method
|
|
435
|
-
const list = this.groupList as unknown as {
|
|
436
|
-
selectedIndex: number;
|
|
437
|
-
items: SettingItem[];
|
|
438
|
-
filteredItems: SettingItem[];
|
|
439
|
-
searchEnabled: boolean;
|
|
440
|
-
};
|
|
441
|
-
const displayItems = list.searchEnabled ? list.filteredItems : list.items;
|
|
442
|
-
return displayItems[list.selectedIndex]?.id ?? null;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
private enterSegmentsMode(groupId: string): void {
|
|
446
|
-
this.selectedGroupId = groupId;
|
|
447
|
-
this.buildSegmentList(groupId);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
private backToGroups(): void {
|
|
451
|
-
this.selectedGroupId = null;
|
|
452
|
-
this.segmentList = null;
|
|
453
|
-
// Rebuild group list to reflect segment changes
|
|
454
|
-
this.buildGroupList();
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
// ─── Render ────────────────────────────────────────────────────────
|
|
458
|
-
|
|
459
|
-
render(width: number): string[] {
|
|
460
|
-
const innerWidth = boxInnerWidth(width);
|
|
461
|
-
const lines: string[] = [];
|
|
462
|
-
|
|
463
|
-
// Header
|
|
464
|
-
lines.push(overlay.borderLine(innerWidth, "top"));
|
|
465
|
-
lines.push(overlay.frameLine(`\x1b[1m\x1b[36m⚙ Footer Settings\x1b[0m`, innerWidth));
|
|
466
|
-
|
|
467
|
-
// Section tabs
|
|
468
|
-
const tabParts = SECTIONS.map((s) => {
|
|
469
|
-
const label = SECTION_LABELS[s];
|
|
470
|
-
if (s === this.section) {
|
|
471
|
-
return `\x1b[1m\x1b[36m[${label}]\x1b[0m`;
|
|
472
|
-
}
|
|
473
|
-
return `\x1b[2m${label}\x1b[0m`;
|
|
474
|
-
});
|
|
475
|
-
lines.push(overlay.frameLine(` ${tabParts.join(" ")}`, innerWidth));
|
|
476
|
-
lines.push(overlay.ruleLine(innerWidth));
|
|
477
|
-
|
|
478
|
-
// Section content
|
|
479
|
-
const activeList = this.currentList;
|
|
480
|
-
if (activeList) {
|
|
481
|
-
const contentLines = activeList.render(innerWidth - 2);
|
|
482
|
-
for (const line of contentLines) {
|
|
483
|
-
lines.push(overlay.frameLine(` ${line}`, innerWidth));
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
// Footer hints
|
|
488
|
-
lines.push(overlay.ruleLine(innerWidth));
|
|
489
|
-
|
|
490
|
-
let hints: string;
|
|
491
|
-
if (this.section === "segments" && this.selectedGroupId) {
|
|
492
|
-
hints = "j/k navigate · Space toggle · h/Esc back · / search · q close";
|
|
493
|
-
} else if (this.section === "segments") {
|
|
494
|
-
hints = "j/k navigate · Space toggle · l/Enter segments · / search · q close";
|
|
495
|
-
} else {
|
|
496
|
-
hints = "j/k navigate · Space/Enter change · Tab section · q close";
|
|
497
|
-
}
|
|
498
|
-
lines.push(overlay.frameLine(`\x1b[2m${hints}\x1b[0m`, innerWidth));
|
|
499
|
-
lines.push(overlay.borderLine(innerWidth, "bottom"));
|
|
500
|
-
|
|
501
|
-
return lines;
|
|
502
|
-
}
|
|
503
|
-
}
|