@pi-unipi/footer 2.20.4 → 3.0.0-alpha.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
@@ -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:footer-settings` → Appearance → **Glance Footer**:
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:footer-settings` | Open settings TUI — preset, separator, icon style, glance mode, per-segment toggles |
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": "2.20.4",
4
- "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
3
+ "version": "3.0.0-alpha.0",
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": "2.20.4",
36
- "@pi-unipi/background-tasks": "2.20.4"
35
+ "@pi-unipi/core": "3.0.0-alpha.0",
36
+ "@pi-unipi/background-tasks": "3.0.0-alpha.0"
37
37
  },
38
38
  "peerDependencies": {
39
- "@earendil-works/pi-coding-agent": "^0.84.0",
40
- "@earendil-works/pi-tui": "^0.84.0"
39
+ "@earendil-works/pi-coding-agent": "^0.86.0",
40
+ "@earendil-works/pi-tui": "^0.86.0"
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), /unipi:footer <preset>,
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 { FooterGroup, FooterSegment } from "./types.js";
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
- export function loadFooterSettings(): FooterSettings {
84
- const raw = readSettingsFile();
85
- if (!raw) return { ...DEFAULT_FOOTER_SETTINGS };
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
- const raw = readSettingsFile() ?? {};
120
- const unipi = (raw[UNIPI_SETTINGS_KEY] as Record<string, unknown>) ?? {};
121
- const existing = (unipi.footer as Record<string, unknown>) ?? {};
122
-
123
- unipi.footer = { ...existing, ...partial };
124
- raw[UNIPI_SETTINGS_KEY] = unipi;
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(
@@ -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
- // Width ledger counts PLAIN text — strip SGR (rainbow codes) first.
226
- const topFiller = Math.max(
227
- 2,
228
- safe - 2 - visibleWidth(stripControls(leadRule + titleText)),
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, ALL_GROUPS);
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.FOOTER_SETTINGS}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`],
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",
@@ -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
+ ];
@@ -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
- }