@pi-unipi/footer 0.1.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.
@@ -0,0 +1,109 @@
1
+ /**
2
+ * @pi-unipi/footer — Ralph segments
3
+ *
4
+ * Segment renderers for the ralph group: active_loops, total_iterations, loop_status.
5
+ * Data sourced from RALPH_LOOP_START/END/ITERATION_DONE events via registry cache.
6
+ *
7
+ * Display logic:
8
+ * - When loop is active: green dot ● + iteration stats (e.g. 1/3)
9
+ * - When loop is off: red dot ●
10
+ * - Uses 󰼉 icon for the ralph group
11
+ */
12
+
13
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment, SemanticColor } from "../types.js";
14
+ import { applyColor } from "../rendering/theme.js";
15
+ import { getIcon } from "../rendering/icons.js";
16
+
17
+ /** Nerd Font icon for ralph: 󰼉 */
18
+ const RALPH_ICON = "\udb81\udf09";
19
+
20
+ /** Green dot indicator (with explicit ANSI codes) */
21
+ const GREEN_DOT = "\x1b[38;5;82m●\x1b[0m";
22
+ /** Red dot indicator (with explicit ANSI codes) */
23
+ const RED_DOT = "\x1b[38;5;196m●\x1b[0m";
24
+
25
+ const ANSI_RESET = "\x1b[0m";
26
+
27
+ function withIcon(segmentId: string, text: string): string {
28
+ const icon = getIcon(segmentId);
29
+ return icon ? `${icon} ${text}` : text;
30
+ }
31
+
32
+ function getRalphData(ctx: FooterSegmentContext): Record<string, unknown> {
33
+ const data = ctx.data;
34
+ if (!data || typeof data !== "object") return {};
35
+ return data as Record<string, unknown>;
36
+ }
37
+
38
+ /** Apply semantic color to plain text (without overriding embedded ANSI codes) */
39
+ function colorText(ctx: FooterSegmentContext, semantic: SemanticColor, text: string): string {
40
+ return applyColor(semantic, text, ctx.theme, ctx.colors);
41
+ }
42
+
43
+ function renderActiveLoopsSegment(ctx: FooterSegmentContext): RenderedSegment {
44
+ const data = getRalphData(ctx);
45
+ const active = data.active === true;
46
+ const name = data.name as string | undefined;
47
+ const iteration = data.iteration as number | undefined;
48
+ const maxIterations = data.maxIterations as number | undefined;
49
+
50
+ // Always show when there's ralph data (even when off, to show red dot)
51
+ if (!active && !name && iteration === undefined) return { content: "", visible: false };
52
+
53
+ const dot = active ? GREEN_DOT : RED_DOT;
54
+
55
+ if (active) {
56
+ // Active: green dot + iteration stats
57
+ const iterStr = iteration !== undefined
58
+ ? (maxIterations ? `${iteration}/${maxIterations}` : `${iteration}`)
59
+ : "";
60
+ const nameStr = name ? ` ${name}` : "";
61
+ // Color the icon and text parts, keep dot's own color
62
+ const iconAndText = `${RALPH_ICON} ${iterStr}${nameStr}`;
63
+ const coloredText = colorText(ctx, "ralphOn", iconAndText);
64
+ // Insert the dot after the icon
65
+ const content = `${RALPH_ICON} ${dot} ${colorText(ctx, "ralphOn", `${iterStr}${nameStr}`)}`;
66
+ return { content, visible: true };
67
+ } else {
68
+ // Off/inactive: red dot
69
+ const content = `${RALPH_ICON} ${dot}`;
70
+ return { content: `${colorText(ctx, "ralphOff", RALPH_ICON)} ${dot}`, visible: true };
71
+ }
72
+ }
73
+
74
+ function renderTotalIterationsSegment(ctx: FooterSegmentContext): RenderedSegment {
75
+ const data = getRalphData(ctx);
76
+ const active = data.active === true;
77
+ const lastIteration = data.lastIteration as Record<string, unknown> | undefined;
78
+ const iteration = data.iteration ?? lastIteration?.iteration;
79
+ if (iteration === undefined || iteration === null) return { content: "", visible: false };
80
+ const maxIterations = data.maxIterations;
81
+ const display = maxIterations ? `${iteration}/${maxIterations}` : `${iteration}`;
82
+
83
+ const dot = active ? GREEN_DOT : RED_DOT;
84
+ const semantic: SemanticColor = active ? "ralphOn" : "ralphOff";
85
+ const content = `${colorText(ctx, semantic, RALPH_ICON)} ${dot} ${colorText(ctx, semantic, display)}`;
86
+ return { content, visible: true };
87
+ }
88
+
89
+ function renderLoopStatusSegment(ctx: FooterSegmentContext): RenderedSegment {
90
+ const data = getRalphData(ctx);
91
+ const status = data.status as string | undefined;
92
+ const name = data.name as string | undefined;
93
+ if (!status && !name) return { content: "", visible: false };
94
+
95
+ const dot = status === "active" ? GREEN_DOT : status === "completed" ? GREEN_DOT : RED_DOT;
96
+ const statusIcon = status === "active" ? "▶" : status === "paused" ? "⏸" : status === "completed" ? "✓" : "";
97
+ const display = name ? `${statusIcon} ${name}` : `${statusIcon}`;
98
+
99
+ const active = status === "active" || status === "completed";
100
+ const semantic: SemanticColor = active ? "ralphOn" : "ralphOff";
101
+ const content = `${colorText(ctx, semantic, RALPH_ICON)} ${dot} ${colorText(ctx, semantic, display)}`;
102
+ return { content, visible: true };
103
+ }
104
+
105
+ export const RALPH_SEGMENTS: FooterSegment[] = [
106
+ { id: "active_loops", label: "Active Loops", icon: "", render: renderActiveLoopsSegment, defaultShow: true },
107
+ { id: "total_iterations", label: "Total Iterations", icon: "", render: renderTotalIterationsSegment, defaultShow: true },
108
+ { id: "loop_status", label: "Loop Status", icon: "", render: renderLoopStatusSegment, defaultShow: true },
109
+ ];
@@ -0,0 +1,119 @@
1
+ /**
2
+ * @pi-unipi/footer — Status extension segment
3
+ *
4
+ * Renders extension status entries from footerData.getExtensionStatuses().
5
+ * Uses the configured separator between entries and the current icon style.
6
+ *
7
+ * Status keys from packages:
8
+ * "unipi-workflow" → "⚡ wf:brainstorm ✓ rl" (active command shown)
9
+ * "ralph" → "rl:loop-name 3/50"
10
+ * "unipi-memory" → "⚡ mem 75p/101all"
11
+ * "subagents" → various
12
+ */
13
+
14
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment } from "../types.js";
15
+ import { getIcon } from "../rendering/icons.js";
16
+ import { loadFooterSettings } from "../config.js";
17
+ import { getSeparator } from "../rendering/separators.js";
18
+
19
+ /** Map status keys to short display names and segment IDs for icons */
20
+ const STATUS_DISPLAY: Record<string, { short: string; segmentId: string }> = {
21
+ "unipi-workflow": { short: "wf", segmentId: "currentCommand" },
22
+ workflow: { short: "wf", segmentId: "currentCommand" },
23
+ ralph: { short: "rl", segmentId: "activeLoops" },
24
+ "unipi-memory": { short: "mem", segmentId: "projectCount" },
25
+ memory: { short: "mem", segmentId: "projectCount" },
26
+ compactor: { short: "cmp", segmentId: "compactions" },
27
+ mcp: { short: "mcp", segmentId: "serversTotal" },
28
+ notify: { short: "ntf", segmentId: "platformsEnabled" },
29
+ kanboard: { short: "kb", segmentId: "docsCount" },
30
+ info: { short: "info", segmentId: "extensionStatuses" },
31
+ subagents: { short: "sa", segmentId: "extensionStatuses" },
32
+ };
33
+
34
+ /** Get the separator character for the current settings */
35
+ function getStatusSeparator(): string {
36
+ const settings = loadFooterSettings();
37
+ const sepDef = getSeparator(settings.separator);
38
+ return sepDef.left;
39
+ }
40
+
41
+ /**
42
+ * Strip any leading emoji/symbol from a status value.
43
+ * The packages set their own icons (⚡, 🔄, 📝, ○, ✓) which we replace
44
+ * with our own based on the configured icon style.
45
+ */
46
+ function stripLeadingSymbol(value: string): string {
47
+ // Remove common emoji/symbol prefixes (1-2 chars + optional space)
48
+ return value.replace(/^[\u2600-\u27BF\u2300-\u23FF\u2B50\u25CF\u25CB\u25B6\u23F3\u26A1\u{1F300}-\u{1F9FF}]\s*/u, "");
49
+ }
50
+
51
+ /**
52
+ * Clean up a status value by stripping the package name prefix
53
+ * and existing icons, returning just the meaningful content.
54
+ */
55
+ function cleanStatusValue(key: string, value: string): string {
56
+ // First strip any leading emoji/symbol
57
+ let cleaned = stripLeadingSymbol(value);
58
+
59
+ // Strip known package name prefixes
60
+ const namePatterns: Record<string, RegExp> = {
61
+ "unipi-workflow": /^wf:?\s*/i,
62
+ workflow: /^wf:?\s*/i,
63
+ "unipi-memory": /^mem:?\s*/i,
64
+ memory: /^mem:?\s*/i,
65
+ ralph: /^rl:?\s*/i,
66
+ };
67
+
68
+ const pattern = namePatterns[key.toLowerCase()];
69
+ if (pattern) {
70
+ cleaned = cleaned.replace(pattern, "");
71
+ }
72
+
73
+ return cleaned.trim();
74
+ }
75
+
76
+ function renderExtensionStatusesSegment(ctx: FooterSegmentContext): RenderedSegment {
77
+ const footerData = ctx.footerData as any;
78
+ if (!footerData || typeof footerData.getExtensionStatuses !== "function") {
79
+ return { content: "", visible: false };
80
+ }
81
+
82
+ const statuses = footerData.getExtensionStatuses() as Map<string, string>;
83
+ if (!statuses || statuses.size === 0) return { content: "", visible: false };
84
+
85
+ const sep = getStatusSeparator();
86
+
87
+ // Collect compact status strings with icons
88
+ const parts: string[] = [];
89
+ for (const [key, value] of statuses) {
90
+ if (!value || !value.trim()) continue;
91
+
92
+ // Strip ANSI codes for compact display
93
+ const stripped = value.replace(/\x1b\[[0-9;]*m/g, "").trim();
94
+ if (!stripped) continue;
95
+
96
+ const display = STATUS_DISPLAY[key.toLowerCase()];
97
+ const icon = display
98
+ ? getIcon(display.segmentId)
99
+ : getIcon("extensionStatuses");
100
+
101
+ const shortName = display?.short ?? key;
102
+ const extraContent = cleanStatusValue(key, stripped);
103
+
104
+ // Format: "icon shortName extraContent" or "icon shortName"
105
+ const part = extraContent
106
+ ? (icon ? `${icon} ${shortName} ${extraContent}` : `${shortName} ${extraContent}`)
107
+ : (icon ? `${icon} ${shortName}` : shortName);
108
+ parts.push(part);
109
+ }
110
+
111
+ if (parts.length === 0) return { content: "", visible: false };
112
+
113
+ const content = parts.join(` ${sep} `);
114
+ return { content, visible: true };
115
+ }
116
+
117
+ export const STATUS_EXT_SEGMENTS: FooterSegment[] = [
118
+ { id: "extension_statuses", label: "Extensions", icon: "", render: renderExtensionStatusesSegment, defaultShow: true },
119
+ ];
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @pi-unipi/footer — Workflow segments
3
+ *
4
+ * Segment renderers for the workflow group: current_command, sandbox_level,
5
+ * command_duration.
6
+ * Data sourced from WORKFLOW_START/END events via registry cache.
7
+ */
8
+
9
+ import type { FooterSegment, FooterSegmentContext, RenderedSegment, SemanticColor } from "../types.js";
10
+ import { applyColor } from "../rendering/theme.js";
11
+ import { getIcon } from "../rendering/icons.js";
12
+
13
+ /** Nerd Font icon for workflow: */
14
+ const WORKFLOW_ICON = "\uf52e";
15
+
16
+ function withIcon(segmentId: string, text: string): string {
17
+ const icon = getIcon(segmentId);
18
+ return icon ? `${icon} ${text}` : text;
19
+ }
20
+
21
+ function getWorkflowData(ctx: FooterSegmentContext): Record<string, unknown> {
22
+ const data = ctx.data;
23
+ if (!data || typeof data !== "object") return {};
24
+ return data as Record<string, unknown>;
25
+ }
26
+
27
+ /** Map a workflow command name to a semantic color for slight differentiation */
28
+ function getWorkflowSemanticColor(command: string): SemanticColor {
29
+ const commandLower = command.toLowerCase();
30
+
31
+ if (commandLower.includes("brainstorm")) return "workflowBrainstorm";
32
+ if (commandLower.includes("plan")) return "workflowPlan";
33
+ if (commandLower.includes("work") && !commandLower.includes("network") && !commandLower.includes("framework")) return "workflowWork";
34
+ if (commandLower.includes("review")) return "workflowReview";
35
+ if (commandLower.includes("auto")) return "workflowAuto";
36
+ if (commandLower.includes("fix") || commandLower.includes("debug")) return "workflowWork";
37
+ if (commandLower.includes("quick")) return "workflowOther";
38
+ if (commandLower.includes("document")) return "workflowPlan";
39
+ if (commandLower.includes("consolidate")) return "workflowOther";
40
+
41
+ return "workflow";
42
+ }
43
+
44
+ function renderCurrentCommandSegment(ctx: FooterSegmentContext): RenderedSegment {
45
+ const data = getWorkflowData(ctx);
46
+ const active = data.active === true;
47
+ const command = data.command as string | undefined;
48
+
49
+ // No workflow — show dash
50
+ if (!command) {
51
+ const content = `${WORKFLOW_ICON} -`;
52
+ return { content: applyColor("workflow", content, ctx.theme, ctx.colors), visible: true };
53
+ }
54
+
55
+ const statusPrefix = active ? "▶" : "✓";
56
+ const semanticColor = getWorkflowSemanticColor(command);
57
+ const content = `${WORKFLOW_ICON} ${statusPrefix} ${command}`;
58
+ return { content: applyColor(semanticColor, content, ctx.theme, ctx.colors), visible: true };
59
+ }
60
+
61
+ function renderSandboxLevelSegment(ctx: FooterSegmentContext): RenderedSegment {
62
+ const piCtx = ctx.piContext as Record<string, unknown> | undefined;
63
+ // Sandbox level is not directly exposed — show a generic indicator
64
+ const sandboxEnabled = true; // Default assumption
65
+ const content = withIcon("sandboxLevel", sandboxEnabled ? "sandbox" : "full");
66
+ return { content: applyColor("workflow", content, ctx.theme, ctx.colors), visible: true };
67
+ }
68
+
69
+ function renderCommandDurationSegment(ctx: FooterSegmentContext): RenderedSegment {
70
+ const data = getWorkflowData(ctx);
71
+ const startTime = data.startTime as number | undefined;
72
+ const durationMs = data.durationMs as number | undefined;
73
+
74
+ let display: string;
75
+ if (durationMs !== undefined) {
76
+ display = formatDuration(durationMs);
77
+ } else if (startTime) {
78
+ display = formatDuration(Date.now() - startTime);
79
+ } else {
80
+ return { content: "", visible: false };
81
+ }
82
+
83
+ const content = withIcon("commandDuration", display);
84
+ return { content: applyColor("workflow", content, ctx.theme, ctx.colors), visible: true };
85
+ }
86
+
87
+ function formatDuration(ms: number): string {
88
+ const seconds = Math.floor(ms / 1000);
89
+ const minutes = Math.floor(seconds / 60);
90
+ const hours = Math.floor(minutes / 60);
91
+ if (hours > 0) return `${hours}h${minutes % 60}m`;
92
+ if (minutes > 0) return `${minutes}m${seconds % 60}s`;
93
+ return `${seconds}s`;
94
+ }
95
+
96
+ export const WORKFLOW_SEGMENTS: FooterSegment[] = [
97
+ { id: "current_command", label: "Current Command", icon: "", render: renderCurrentCommandSegment, defaultShow: true },
98
+ { id: "sandbox_level", label: "Sandbox Level", icon: "", render: renderSandboxLevelSegment, defaultShow: false },
99
+ { id: "command_duration", label: "Command Duration", icon: "", render: renderCommandDurationSegment, defaultShow: true },
100
+ ];
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @pi-unipi/footer — Settings TUI
3
+ *
4
+ * Interactive settings overlay for toggling groups and individual segments.
5
+ * Follows the info-screen SettingsOverlay pattern.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import { loadFooterSettings, saveFooterSettings, getGroupSettings } from "../config.js";
11
+ import type { FooterGroup, FooterSettings } from "../types.js";
12
+
13
+ /** ANSI escape codes */
14
+ const ansi = {
15
+ reset: "\x1b[0m",
16
+ bold: "\x1b[1m",
17
+ dim: "\x1b[2m",
18
+ cyan: "\x1b[36m",
19
+ green: "\x1b[32m",
20
+ yellow: "\x1b[33m",
21
+ gray: "\x1b[90m",
22
+ };
23
+
24
+ const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
25
+ const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
26
+
27
+ /**
28
+ * Show the footer settings overlay.
29
+ */
30
+ export function showFooterSettings(ctx: any, groups: FooterGroup[]): void {
31
+ ctx.ui.custom(
32
+ (tui: any, _theme: any, _keybindings: any, done: (result: void) => void) => {
33
+ const overlay = new FooterSettingsOverlay(groups);
34
+
35
+ overlay.onClose = () => done();
36
+
37
+ return {
38
+ focused: true,
39
+ invalidate: () => overlay.invalidate(),
40
+ render: (width: number) => overlay.render(width),
41
+ handleInput: (data: string) => {
42
+ overlay.handleInput(data);
43
+ tui.requestRender();
44
+ },
45
+ dispose: () => {},
46
+ };
47
+ },
48
+ {
49
+ overlay: true,
50
+ overlayOptions: () => ({
51
+ verticalAlign: "center",
52
+ horizontalAlign: "center",
53
+ }),
54
+ },
55
+ ).catch((err: unknown) => {
56
+ console.error("[footer] Settings overlay error:", err);
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Footer settings overlay component.
62
+ */
63
+ class FooterSettingsOverlay {
64
+ private settings: FooterSettings;
65
+ private groups: FooterGroup[];
66
+ private selectedIndex = 0;
67
+ private savedGroupIndex = 0;
68
+ private mode: "groups" | "segments" = "groups";
69
+ private selectedGroupId: string | null = null;
70
+ onClose?: () => void;
71
+
72
+ constructor(groups: FooterGroup[]) {
73
+ this.settings = loadFooterSettings();
74
+ this.groups = groups;
75
+ }
76
+
77
+ invalidate(): void {
78
+ // No cached state
79
+ }
80
+
81
+ handleInput(data: string): void {
82
+ if (this.mode === "groups") {
83
+ this.handleGroupsInput(data);
84
+ } else {
85
+ this.handleSegmentsInput(data);
86
+ }
87
+ }
88
+
89
+ private handleGroupsInput(data: string): void {
90
+ switch (data) {
91
+ case "\x1b[A": case "k":
92
+ this.selectedIndex = (this.selectedIndex - 1 + this.groups.length) % this.groups.length;
93
+ break;
94
+ case "\x1b[B": case "j":
95
+ this.selectedIndex = (this.selectedIndex + 1) % this.groups.length;
96
+ break;
97
+ case " ":
98
+ this.toggleGroup(this.groups[this.selectedIndex].id);
99
+ break;
100
+ case "\r": case "\x1b[C": case "l":
101
+ this.enterSegmentsMode(this.groups[this.selectedIndex].id);
102
+ break;
103
+ case "q": case "\x1b":
104
+ this.onClose?.();
105
+ break;
106
+ }
107
+ }
108
+
109
+ private handleSegmentsInput(data: string): void {
110
+ if (!this.selectedGroupId) return;
111
+ const group = this.groups.find(g => g.id === this.selectedGroupId);
112
+ if (!group) return;
113
+
114
+ switch (data) {
115
+ case "\x1b[A": case "k":
116
+ this.selectedIndex = (this.selectedIndex - 1 + group.segments.length) % group.segments.length;
117
+ break;
118
+ case "\x1b[B": case "j":
119
+ this.selectedIndex = (this.selectedIndex + 1) % group.segments.length;
120
+ break;
121
+ case " ":
122
+ this.toggleSegment(this.selectedGroupId, group.segments[this.selectedIndex].id);
123
+ break;
124
+ case "\x1b[D": case "h": case "\r":
125
+ this.backToGroups();
126
+ break;
127
+ case "q": case "\x1b":
128
+ this.onClose?.();
129
+ break;
130
+ }
131
+ }
132
+
133
+ private toggleGroup(groupId: string): void {
134
+ const groupSettings = this.settings.groups[groupId] ?? { show: true, segments: {} };
135
+ groupSettings.show = !groupSettings.show;
136
+ this.settings.groups[groupId] = groupSettings;
137
+ saveFooterSettings(this.settings);
138
+ }
139
+
140
+ private toggleSegment(groupId: string, segmentId: string): void {
141
+ const groupSettings = this.settings.groups[groupId] ?? { show: true, segments: {} };
142
+ if (!groupSettings.segments) groupSettings.segments = {};
143
+ groupSettings.segments[segmentId] = !(groupSettings.segments[segmentId] ?? true);
144
+ this.settings.groups[groupId] = groupSettings;
145
+ saveFooterSettings(this.settings);
146
+ }
147
+
148
+ private enterSegmentsMode(groupId: string): void {
149
+ this.savedGroupIndex = this.selectedIndex;
150
+ this.mode = "segments";
151
+ this.selectedGroupId = groupId;
152
+ this.selectedIndex = 0;
153
+ }
154
+
155
+ private backToGroups(): void {
156
+ this.mode = "groups";
157
+ this.selectedIndex = this.savedGroupIndex;
158
+ this.selectedGroupId = null;
159
+ }
160
+
161
+ render(width: number): string[] {
162
+ if (this.mode === "groups") {
163
+ return this.renderGroupsMode(width);
164
+ } else {
165
+ return this.renderSegmentsMode(width);
166
+ }
167
+ }
168
+
169
+ private padToWidth(line: string, targetWidth: number): string {
170
+ const visLen = visibleWidth(line);
171
+ const pad = Math.max(0, targetWidth - visLen);
172
+ return line + " ".repeat(pad);
173
+ }
174
+
175
+ private renderCentered(text: string, width: number): string {
176
+ const visLen = visibleWidth(text);
177
+ if (visLen >= width) return text;
178
+ const leftPad = Math.floor((width - visLen) / 2);
179
+ return " ".repeat(leftPad) + text;
180
+ }
181
+
182
+ private renderGroupsMode(width: number): string[] {
183
+ const lines: string[] = [];
184
+ const innerWidth = width - 2;
185
+
186
+ lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
187
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${ansi.bold}⚙ Footer Settings${ansi.reset}`, innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
188
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
189
+
190
+ for (let i = 0; i < this.groups.length; i++) {
191
+ const group = this.groups[i];
192
+ const isSelected = i === this.selectedIndex;
193
+ const groupSettings = this.settings.groups[group.id] ?? { show: group.defaultShow, segments: {} };
194
+ const isEnabled = groupSettings.show;
195
+
196
+ const toggle = isEnabled ? TOGGLE_ON : TOGGLE_OFF;
197
+ const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
198
+ let line = ` ${indicator} ${toggle} ${group.name}`;
199
+
200
+ if (isSelected) {
201
+ line += ` ${ansi.dim}→ segments${ansi.reset}`;
202
+ }
203
+
204
+ if (visibleWidth(line) > innerWidth - 2) {
205
+ line = truncateToWidth(line, innerWidth - 2);
206
+ }
207
+
208
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
209
+ }
210
+
211
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
212
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${ansi.dim}↑↓ select Space toggle Enter/→ segments q close${ansi.reset}`, innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
213
+ lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
214
+
215
+ return lines;
216
+ }
217
+
218
+ private renderSegmentsMode(width: number): string[] {
219
+ const lines: string[] = [];
220
+ const group = this.groups.find(g => g.id === this.selectedGroupId);
221
+ if (!group) return lines;
222
+
223
+ const groupSettings = this.settings.groups[group.id] ?? { show: group.defaultShow, segments: {} };
224
+ const innerWidth = width - 2;
225
+
226
+ lines.push(`${ansi.dim}╭${"─".repeat(innerWidth)}╮${ansi.reset}`);
227
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${group.name} Segments`, innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
228
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
229
+
230
+ for (let i = 0; i < group.segments.length; i++) {
231
+ const seg = group.segments[i];
232
+ const isSelected = i === this.selectedIndex;
233
+ const isEnabled = groupSettings.segments?.[seg.id] ?? seg.defaultShow;
234
+
235
+ const toggle = isEnabled ? TOGGLE_ON : TOGGLE_OFF;
236
+ const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
237
+ let line = ` ${indicator} ${toggle} ${seg.label}`;
238
+
239
+ if (visibleWidth(line) > innerWidth - 2) {
240
+ line = truncateToWidth(line, innerWidth - 2);
241
+ }
242
+
243
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
244
+ }
245
+
246
+ lines.push(`${ansi.dim}├${"─".repeat(innerWidth)}┤${ansi.reset}`);
247
+ lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(this.renderCentered(`${ansi.dim}↑↓ select Space toggle ←/Enter back q close${ansi.reset}`, innerWidth), innerWidth)}${ansi.dim}│${ansi.reset}`);
248
+ lines.push(`${ansi.dim}╰${"─".repeat(innerWidth)}╯${ansi.reset}`);
249
+
250
+ return lines;
251
+ }
252
+ }