@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.
- package/README.md +206 -0
- package/index.ts +6 -0
- package/package.json +52 -0
- package/src/commands.ts +204 -0
- package/src/config.ts +177 -0
- package/src/events.ts +256 -0
- package/src/index.ts +208 -0
- package/src/presets.ts +131 -0
- package/src/registry/index.ts +162 -0
- package/src/rendering/icons.ts +318 -0
- package/src/rendering/renderer.ts +310 -0
- package/src/rendering/separators.ts +112 -0
- package/src/rendering/theme.ts +98 -0
- package/src/segments/compactor.ts +135 -0
- package/src/segments/core.ts +283 -0
- package/src/segments/kanboard.ts +75 -0
- package/src/segments/mcp.ts +100 -0
- package/src/segments/memory.ts +140 -0
- package/src/segments/notify.ts +50 -0
- package/src/segments/ralph.ts +109 -0
- package/src/segments/status-ext.ts +119 -0
- package/src/segments/workflow.ts +100 -0
- package/src/tui/settings-tui.ts +252 -0
- package/src/types.ts +183 -0
|
@@ -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
|
+
}
|