@pi-unipi/footer 0.1.2 → 0.1.3

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.
@@ -2,36 +2,59 @@
2
2
  * @pi-unipi/footer — Settings TUI
3
3
  *
4
4
  * Interactive settings overlay for toggling groups and individual segments.
5
- * Follows the info-screen SettingsOverlay pattern.
5
+ * Uses pi-tui SettingsList for proper vim/arrow keybinding support and search.
6
+ * Modeled after the compactor settings overlay pattern.
6
7
  */
7
8
 
8
9
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
- import { loadFooterSettings, saveFooterSettings, getGroupSettings } from "../config.js";
10
+ import { SettingsList, type SettingItem, type SettingsListTheme } from "@mariozechner/pi-tui";
11
+ import { loadFooterSettings, saveFooterSettings } from "../config.js";
11
12
  import type { FooterGroup, FooterSettings } from "../types.js";
12
13
 
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",
14
+ // ─── Section types ─────────────────────────────────────────────────────
15
+
16
+ type Section = "groups" | "segments";
17
+ const SECTIONS: Section[] = ["groups", "segments"];
18
+
19
+ // ─── Theme for SettingsList ────────────────────────────────────────────
20
+
21
+ const THEME: SettingsListTheme = {
22
+ label: (text, selected) => selected ? `\x1b[1m${text}\x1b[0m` : `\x1b[2m${text}\x1b[0m`,
23
+ value: (text, selected) => selected ? `\x1b[35m${text}\x1b[0m` : `\x1b[35m${text}\x1b[0m`,
24
+ description: (text) => `\x1b[90m${text}\x1b[0m`,
25
+ cursor: `\x1b[36m▸\x1b[0m`,
26
+ hint: (text) => `\x1b[2m${text}\x1b[0m`,
22
27
  };
23
28
 
24
- const TOGGLE_ON = `${ansi.green}●${ansi.reset}`;
25
- const TOGGLE_OFF = `${ansi.dim}○${ansi.reset}`;
29
+ // ─── Helper: frame a line inside box drawing ───────────────────────────
30
+
31
+ function frameLine(content: string, innerWidth: number): string {
32
+ const visLen = visibleWidth(content);
33
+ const pad = Math.max(0, innerWidth - visLen);
34
+ return `\x1b[90m│\x1b[0m${content}${" ".repeat(pad)}\x1b[90m│\x1b[0m`;
35
+ }
36
+
37
+ function ruleLine(innerWidth: number): string {
38
+ return `\x1b[90m├${"─".repeat(innerWidth)}┤\x1b[0m`;
39
+ }
40
+
41
+ function borderLine(innerWidth: number, edge: "top" | "bottom"): string {
42
+ const left = edge === "top" ? "┌" : "└";
43
+ const right = edge === "top" ? "┐" : "┘";
44
+ return `\x1b[90m${left}${"─".repeat(innerWidth)}${right}\x1b[0m`;
45
+ }
46
+
47
+ // Visible width helper (strip ANSI)
48
+ function visibleWidth(text: string): number {
49
+ return text.replace(/\x1b\[[0-9;]*m/g, "").length;
50
+ }
51
+
52
+ // ─── Show the footer settings overlay ──────────────────────────────────
26
53
 
27
- /**
28
- * Show the footer settings overlay.
29
- */
30
54
  export function showFooterSettings(ctx: any, groups: FooterGroup[]): void {
31
55
  ctx.ui.custom(
32
56
  (tui: any, _theme: any, _keybindings: any, done: (result: void) => void) => {
33
57
  const overlay = new FooterSettingsOverlay(groups);
34
-
35
58
  overlay.onClose = () => done();
36
59
 
37
60
  return {
@@ -52,200 +75,238 @@ export function showFooterSettings(ctx: any, groups: FooterGroup[]): void {
52
75
  horizontalAlign: "center",
53
76
  }),
54
77
  },
55
- ).catch((err: unknown) => {
56
- console.error("[footer] Settings overlay error:", err);
78
+ ).catch(() => {
79
+ // Silently ignore — overlay errors are non-blocking.
57
80
  });
58
81
  }
59
82
 
83
+ // ─── Footer settings overlay component ─────────────────────────────────
84
+
60
85
  /**
61
86
  * Footer settings overlay component.
87
+ * Uses SettingsList from pi-tui for proper vim/arrow keybinding support.
88
+ * Two sections: Groups (toggle groups) and Segments (toggle segments within a group).
62
89
  */
63
90
  class FooterSettingsOverlay {
64
91
  private settings: FooterSettings;
65
92
  private groups: FooterGroup[];
66
- private selectedIndex = 0;
67
- private savedGroupIndex = 0;
68
- private mode: "groups" | "segments" = "groups";
93
+ private section: Section = "groups";
69
94
  private selectedGroupId: string | null = null;
70
95
  onClose?: () => void;
71
96
 
97
+ // Per-section SettingsList instances
98
+ private groupList!: SettingsList;
99
+ private segmentList: SettingsList | null = null;
100
+
72
101
  constructor(groups: FooterGroup[]) {
73
102
  this.settings = loadFooterSettings();
74
103
  this.groups = groups;
104
+ this.buildGroupList();
75
105
  }
76
106
 
77
107
  invalidate(): void {
78
- // No cached state
108
+ this.groupList?.invalidate();
109
+ this.segmentList?.invalidate();
79
110
  }
80
111
 
81
112
  handleInput(data: string): void {
82
- if (this.mode === "groups") {
83
- this.handleGroupsInput(data);
84
- } else {
85
- this.handleSegmentsInput(data);
113
+ // Tab switches section (only if segments are available)
114
+ if (data === "\t" || data === "\x1b[Z") {
115
+ if (this.section === "groups" && this.selectedGroupId) {
116
+ this.section = "segments";
117
+ } else {
118
+ this.section = "groups";
119
+ this.selectedGroupId = null;
120
+ }
121
+ return;
86
122
  }
87
- }
88
123
 
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;
124
+ // Escape / q — close
125
+ if (data === "\x1b" || data === "q") {
126
+ this.onClose?.();
127
+ return;
106
128
  }
129
+
130
+ // Enter in groups mode — enter segments for the focused group
131
+ if (data === "\r" && this.section === "groups") {
132
+ const focusedId = this.getFocusedGroupId();
133
+ if (focusedId) {
134
+ this.enterSegmentsMode(focusedId);
135
+ }
136
+ return;
137
+ }
138
+
139
+ // Left arrow / backspace in segments — back to groups
140
+ if (this.section === "segments" && (data === "\x1b[D" || data === "h" || data === "\x7f")) {
141
+ this.backToGroups();
142
+ return;
143
+ }
144
+
145
+ // Delegate to current SettingsList
146
+ this.currentList?.handleInput(data);
107
147
  }
108
148
 
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;
149
+ private get currentList(): SettingsList | null {
150
+ if (this.section === "segments") return this.segmentList;
151
+ return this.groupList;
152
+ }
153
+
154
+ // ─── Build SettingsList instances ──────────────────────────────────
155
+
156
+ private buildGroupList(): void {
157
+ const items: SettingItem[] = this.groups.map((group) => {
158
+ const groupSettings = this.settings.groups[group.id] ?? { show: group.defaultShow, segments: {} };
159
+ const isEnabled = groupSettings.show;
160
+ const segCount = group.segments.length;
161
+ const enabledCount = group.segments.filter(s => {
162
+ const segOverride = groupSettings.segments?.[s.id];
163
+ return segOverride !== undefined ? segOverride : s.defaultShow;
164
+ }).length;
165
+
166
+ return {
167
+ id: group.id,
168
+ label: group.name,
169
+ description: `${enabledCount}/${segCount} segments active`,
170
+ currentValue: isEnabled ? "on" : "off",
171
+ values: ["on", "off"],
172
+ };
173
+ });
174
+
175
+ this.groupList = new SettingsList(
176
+ items,
177
+ Math.min(items.length + 2, 15),
178
+ THEME,
179
+ (id, newValue) => this.onGroupChange(id, newValue),
180
+ () => this.onClose?.(),
181
+ { enableSearch: true },
182
+ );
183
+ }
184
+
185
+ private buildSegmentList(groupId: string): void {
186
+ const group = this.groups.find(g => g.id === groupId);
187
+ if (!group) {
188
+ this.segmentList = null;
189
+ return;
130
190
  }
191
+
192
+ const groupSettings = this.settings.groups[group.id] ?? { show: group.defaultShow, segments: {} };
193
+
194
+ const items: SettingItem[] = group.segments.map((seg) => {
195
+ const isEnabled = groupSettings.segments?.[seg.id] ?? seg.defaultShow;
196
+ return {
197
+ id: seg.id,
198
+ label: seg.label,
199
+ description: "",
200
+ currentValue: isEnabled ? "on" : "off",
201
+ values: ["on", "off"],
202
+ };
203
+ });
204
+
205
+ this.segmentList = new SettingsList(
206
+ items,
207
+ Math.min(items.length + 2, 15),
208
+ THEME,
209
+ (id, newValue) => this.onSegmentChange(groupId, id, newValue),
210
+ () => this.backToGroups(),
211
+ { enableSearch: true },
212
+ );
131
213
  }
132
214
 
133
- private toggleGroup(groupId: string): void {
215
+ // ─── Change handlers ───────────────────────────────────────────────
216
+
217
+ private onGroupChange(groupId: string, newValue: string): void {
134
218
  const groupSettings = this.settings.groups[groupId] ?? { show: true, segments: {} };
135
- groupSettings.show = !groupSettings.show;
219
+ groupSettings.show = newValue === "on";
136
220
  this.settings.groups[groupId] = groupSettings;
137
221
  saveFooterSettings(this.settings);
222
+
223
+ // Update display
224
+ this.groupList.updateValue(groupId, newValue);
138
225
  }
139
226
 
140
- private toggleSegment(groupId: string, segmentId: string): void {
227
+ private onSegmentChange(groupId: string, segmentId: string, newValue: string): void {
141
228
  const groupSettings = this.settings.groups[groupId] ?? { show: true, segments: {} };
142
229
  if (!groupSettings.segments) groupSettings.segments = {};
143
- groupSettings.segments[segmentId] = !(groupSettings.segments[segmentId] ?? true);
230
+ groupSettings.segments[segmentId] = newValue === "on";
144
231
  this.settings.groups[groupId] = groupSettings;
145
232
  saveFooterSettings(this.settings);
233
+
234
+ // Update the segment list display
235
+ this.segmentList?.updateValue(segmentId, newValue);
236
+
237
+ // Update group description (segment count)
238
+ const group = this.groups.find(g => g.id === groupId);
239
+ if (group) {
240
+ const segCount = group.segments.length;
241
+ const enabledCount = group.segments.filter(s => {
242
+ const segOverride = groupSettings.segments?.[s.id];
243
+ return segOverride !== undefined ? segOverride : s.defaultShow;
244
+ }).length;
245
+ this.groupList.updateValue(groupId, groupSettings.show ? "on" : "off");
246
+ }
247
+ }
248
+
249
+ // ─── Section navigation ────────────────────────────────────────────
250
+
251
+ private getFocusedGroupId(): string | null {
252
+ // Walk the group list items in order; return the first one
253
+ // that matches the focused index. Since SettingsList doesn't
254
+ // expose selectedIndex, we track by the group array order.
255
+ return this.selectedGroupId ?? this.groups[0]?.id ?? null;
146
256
  }
147
257
 
148
258
  private enterSegmentsMode(groupId: string): void {
149
- this.savedGroupIndex = this.selectedIndex;
150
- this.mode = "segments";
151
259
  this.selectedGroupId = groupId;
152
- this.selectedIndex = 0;
260
+ this.section = "segments";
261
+ this.buildSegmentList(groupId);
153
262
  }
154
263
 
155
264
  private backToGroups(): void {
156
- this.mode = "groups";
157
- this.selectedIndex = this.savedGroupIndex;
265
+ this.section = "groups";
158
266
  this.selectedGroupId = null;
267
+ this.segmentList = null;
159
268
  }
160
269
 
161
- render(width: number): string[] {
162
- if (this.mode === "groups") {
163
- return this.renderGroupsMode(width);
164
- } else {
165
- return this.renderSegmentsMode(width);
166
- }
167
- }
270
+ // ─── Render ────────────────────────────────────────────────────────
168
271
 
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[] {
272
+ render(width: number): string[] {
273
+ const innerWidth = Math.max(22, width - 2);
183
274
  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
275
 
196
- const toggle = isEnabled ? TOGGLE_ON : TOGGLE_OFF;
197
- const indicator = isSelected ? `${ansi.cyan}▸${ansi.reset}` : " ";
198
- let line = ` ${indicator} ${toggle} ${group.name}`;
276
+ // Header
277
+ lines.push(borderLine(innerWidth, "top"));
278
+ lines.push(frameLine(`\x1b[1m\x1b[36m⚙ Footer Settings\x1b[0m`, innerWidth));
199
279
 
200
- if (isSelected) {
201
- line += ` ${ansi.dim}→ segments${ansi.reset}`;
280
+ // Section tabs
281
+ const tabParts = SECTIONS.map((s) => {
282
+ const label = s.charAt(0).toUpperCase() + s.slice(1);
283
+ if (s === this.section) {
284
+ return `\x1b[1m\x1b[36m[${label}]\x1b[0m`;
202
285
  }
203
-
204
- if (visibleWidth(line) > innerWidth - 2) {
205
- line = truncateToWidth(line, innerWidth - 2);
286
+ if (s === "segments" && !this.selectedGroupId) {
287
+ return `\x1b[2m${label}\x1b[0m`; // dimmed if no group selected
206
288
  }
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);
289
+ return `\x1b[2m${label}\x1b[0m`;
290
+ });
291
+ lines.push(frameLine(` ${tabParts.join(" ")}`, innerWidth));
292
+ lines.push(ruleLine(innerWidth));
293
+
294
+ // Section content (rendered by SettingsList)
295
+ const activeList = this.currentList;
296
+ if (activeList) {
297
+ const contentLines = activeList.render(innerWidth - 2);
298
+ for (const line of contentLines) {
299
+ lines.push(frameLine(` ${line}`, innerWidth));
241
300
  }
242
-
243
- lines.push(`${ansi.dim}│${ansi.reset}${this.padToWidth(line, innerWidth)}${ansi.dim}│${ansi.reset}`);
244
301
  }
245
302
 
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}`);
303
+ // Footer hints
304
+ lines.push(ruleLine(innerWidth));
305
+ const hints = this.section === "groups"
306
+ ? "↑↓ navigate · Space toggle · Enter segments · / search · q close"
307
+ : "↑↓ navigate · Space toggle · ← back · / search · q close";
308
+ lines.push(frameLine(`\x1b[2m${hints}\x1b[0m`, innerWidth));
309
+ lines.push(borderLine(innerWidth, "bottom"));
249
310
 
250
311
  return lines;
251
312
  }