pi-cc-extensions 0.9.1 → 0.9.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.
@@ -7,7 +7,22 @@
7
7
  import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
8
8
  import { Input, SettingsList, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
9
9
  import type { CompactThinkingController } from "../feature/compact-thinking.ts";
10
- import { applyCustomFooter, clearCustomFooter } from "../feature/shell/footer.ts";
10
+ import {
11
+ applyCustomFooter,
12
+ clearCustomFooter,
13
+ getFooterStatusSnapshot,
14
+ } from "../feature/shell/footer.ts";
15
+ import {
16
+ footerChipDescription,
17
+ footerLineOfKey,
18
+ formatFooterChipSummary,
19
+ orderedFooterKeys,
20
+ reorderFooterKey,
21
+ resolveFooterChipLayout,
22
+ shiftFooterKeyLine,
23
+ toggleFooterKeyHidden,
24
+ type FooterChipLayout,
25
+ } from "../feature/shell/footer-layout.ts";
11
26
  import { applyStartupHeader, clearStartupHeader } from "../feature/shell/startup-header.ts";
12
27
  import type { ToolGroupingHooks } from "../renderer/tool/grouping.ts";
13
28
  import {
@@ -59,6 +74,120 @@ function excludeRenderersDescription(names: readonly string[]): string {
59
74
  : `Native renderer for: ${names.join(", ")}. Agent is always native. Enter to toggle.`;
60
75
  }
61
76
 
77
+ function customFooterDescription(enabled: boolean): string {
78
+ if (!enabled) return "Pi native footer restored. Chip layout below still applies when turned on.";
79
+ return "Custom status bar with model, context, cache, cost, git, and plugin chips.";
80
+ }
81
+
82
+ function pluginChipsDescription(): string {
83
+ return `Arrange plugin chips: ${formatFooterChipSummary(config)}. Enter to edit.`;
84
+ }
85
+
86
+ function footerLayoutFromConfig(): FooterChipLayout {
87
+ return {
88
+ footerHiddenKeys: config.footerHiddenKeys,
89
+ footerLine1Keys: config.footerLine1Keys,
90
+ footerLine2Keys: config.footerLine2Keys,
91
+ footerLine3Keys: config.footerLine3Keys,
92
+ };
93
+ }
94
+
95
+ function persistFooterLayout(layout: FooterChipLayout): void {
96
+ updateConfig({
97
+ footerHiddenKeys: layout.footerHiddenKeys,
98
+ footerLine1Keys: layout.footerLine1Keys,
99
+ footerLine2Keys: layout.footerLine2Keys,
100
+ footerLine3Keys: layout.footerLine3Keys,
101
+ });
102
+ }
103
+
104
+ function footerChipItem(
105
+ key: string,
106
+ layout: FooterChipLayout,
107
+ snapshot: ReadonlyMap<string, string>,
108
+ ) {
109
+ const line = footerLineOfKey(layout, key) ?? 2;
110
+ const hidden = layout.footerHiddenKeys.includes(key);
111
+ const live = snapshot.get(key)?.trim() ?? "";
112
+ return {
113
+ id: key,
114
+ label: key,
115
+ description: footerChipDescription(key, live),
116
+ currentValue: hidden ? `line${line} · hidden` : `line${line}`,
117
+ };
118
+ }
119
+
120
+ function buildFooterChipsSubmenu(
121
+ onClose: () => void,
122
+ onLiveChange: () => void,
123
+ ): {
124
+ render: (width: number) => string[];
125
+ invalidate: () => void;
126
+ handleInput: (data: string) => void;
127
+ } {
128
+ const snapshot = () => getFooterStatusSnapshot();
129
+ const resolved = () => resolveFooterChipLayout(footerLayoutFromConfig(), [...snapshot().keys()]);
130
+ const items: ReturnType<typeof footerChipItem>[] = [];
131
+ const rebuildItems = (layout: FooterChipLayout, selectedKey?: string) => {
132
+ const next = orderedFooterKeys(layout).map((key) => footerChipItem(key, layout, snapshot()));
133
+ items.length = 0;
134
+ items.push(...next);
135
+ if (!selectedKey) return 0;
136
+ const index = items.findIndex((item) => item.id === selectedKey);
137
+ return index >= 0 ? index : 0;
138
+ };
139
+ rebuildItems(resolved());
140
+ const list = new SettingsList(
141
+ items,
142
+ Math.min(8, Math.max(4, items.length)),
143
+ getSettingsListTheme(),
144
+ () => {},
145
+ () => onClose(),
146
+ { enableSearch: false },
147
+ );
148
+ const listState = list as unknown as { selectedIndex: number; items: typeof items };
149
+
150
+ const mutate = (transform: (layout: FooterChipLayout, key: string) => FooterChipLayout) => {
151
+ const key = listState.items[listState.selectedIndex]?.id;
152
+ if (!key) return;
153
+ const next = transform(resolved(), key);
154
+ persistFooterLayout(next);
155
+ listState.selectedIndex = rebuildItems(
156
+ resolveFooterChipLayout(next, [...snapshot().keys()]),
157
+ key,
158
+ );
159
+ onLiveChange();
160
+ };
161
+
162
+ return {
163
+ render: (width: number) => list.render(width),
164
+ invalidate: () => list.invalidate(),
165
+ handleInput: (data: string) => {
166
+ if (matchesKey(data, "left")) {
167
+ mutate((layout, key) => shiftFooterKeyLine(layout, key, -1));
168
+ return;
169
+ }
170
+ if (matchesKey(data, "right")) {
171
+ mutate((layout, key) => shiftFooterKeyLine(layout, key, 1));
172
+ return;
173
+ }
174
+ if (data === "[") {
175
+ mutate((layout, key) => reorderFooterKey(layout, key, -1));
176
+ return;
177
+ }
178
+ if (data === "]") {
179
+ mutate((layout, key) => reorderFooterKey(layout, key, 1));
180
+ return;
181
+ }
182
+ if (data === " " || matchesKey(data, "space")) {
183
+ mutate((layout, key) => toggleFooterKeyHidden(layout, key));
184
+ return;
185
+ }
186
+ list.handleInput(data);
187
+ },
188
+ };
189
+ }
190
+
62
191
  function diffViewModeDescription(mode: DiffViewMode): string {
63
192
  if (mode === "split") return "Force side-by-side diff when width allows; otherwise unified.";
64
193
  if (mode === "unified") return "Always render a single unified diff column.";
@@ -186,7 +315,7 @@ function buildNumberInputSubmenu(
186
315
 
187
316
  /** Section tabs for /ccstyle — matches Zentui-style "A / B / C" headers. */
188
317
  type CcstyleSection = {
189
- id: "style" | "diff" | "thinking" | "ui" | "feature";
318
+ id: "style" | "diff" | "thinking" | "ui" | "feature" | "footer";
190
319
  label: string;
191
320
  items: any[];
192
321
  };
@@ -242,19 +371,22 @@ export async function showCcstylePanel(
242
371
  currentValue: config.mode === "compact" ? "compact (Experimental)" : config.mode,
243
372
  values: ["on", "compact (Experimental)", "off"],
244
373
  };
245
- // Tracks whether the Exclude-tools submenu is open so Tab switches sections
374
+ // Tracks whether a nested submenu is open so Tab switches sections
246
375
  // only at the top level (mirrors Zentui settings: Tab = switch sections).
247
- let excludeSubmenuOpen = false;
376
+ let nestedSubmenuOpen = false;
377
+ let nestedHint = "";
248
378
  const excludeSetting = {
249
379
  id: "excludeRenderers",
250
380
  label: "Exclude tools",
251
381
  description: excludeRenderersDescription(config.excludeRenderers),
252
382
  currentValue: formatExcludeRenderers(config.excludeRenderers),
253
383
  submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
254
- excludeSubmenuOpen = true;
384
+ nestedSubmenuOpen = true;
385
+ nestedHint = " Enter/Space to toggle · Esc back to Style";
255
386
  return buildExcludeRenderersSubmenu(
256
387
  () => {
257
- excludeSubmenuOpen = false;
388
+ nestedSubmenuOpen = false;
389
+ nestedHint = "";
258
390
  excludeSetting.currentValue = formatExcludeRenderers(config.excludeRenderers);
259
391
  excludeSetting.description = excludeRenderersDescription(config.excludeRenderers);
260
392
  closeSubmenu();
@@ -456,13 +588,45 @@ export async function showCcstylePanel(
456
588
  "Aliases disabled.",
457
589
  config.enableAliases,
458
590
  );
459
- const customFooterToggle = featureToggleSetting(
460
- "enableCustomFooter",
461
- "Status bar",
462
- "Two-line chips status bar with model, context, git, and usage. Applies immediately. Needs a Nerd Font.",
463
- "Pi native footer restored.",
464
- config.enableCustomFooter,
465
- );
591
+ const footerNerdIconsSetting = {
592
+ id: "footerNerdIcons",
593
+ label: "Nerd Font icons",
594
+ description: config.footerNerdIcons
595
+ ? "Git and cache chips use Nerd Font glyphs. Turn off for plain text."
596
+ : "Git and cache chips use plain text. No Nerd Font required.",
597
+ currentValue: config.footerNerdIcons ? "on" : "off",
598
+ values: ["on", "off"],
599
+ };
600
+ const customFooterSetting = {
601
+ id: "enableCustomFooter",
602
+ label: "Status bar",
603
+ description: customFooterDescription(config.enableCustomFooter),
604
+ currentValue: config.enableCustomFooter ? "on" : "off",
605
+ values: ["on", "off"],
606
+ };
607
+ const pluginChipsSetting = {
608
+ id: "footerPluginChips",
609
+ label: "Plugin chips",
610
+ description: pluginChipsDescription(),
611
+ currentValue: formatFooterChipSummary(config),
612
+ submenu: (_current: string, closeSubmenu: (selected?: string) => void) => {
613
+ nestedSubmenuOpen = true;
614
+ nestedHint = " ←→ line · [ ] reorder · Space hide · Esc back to Footer";
615
+ return buildFooterChipsSubmenu(
616
+ () => {
617
+ nestedSubmenuOpen = false;
618
+ nestedHint = "";
619
+ pluginChipsSetting.currentValue = formatFooterChipSummary(config);
620
+ pluginChipsSetting.description = pluginChipsDescription();
621
+ closeSubmenu();
622
+ },
623
+ () => {
624
+ pluginChipsSetting.currentValue = formatFooterChipSummary(config);
625
+ pluginChipsSetting.description = pluginChipsDescription();
626
+ },
627
+ );
628
+ },
629
+ };
466
630
  const featureToggles: Record<string, { apply: (on: boolean) => void }> = {
467
631
  enableSessionReference: sessionReferenceToggle,
468
632
  enableSubagentAutocomplete: subagentAutocompleteToggle,
@@ -476,12 +640,23 @@ export async function showCcstylePanel(
476
640
  if (id === "enableCustomFooter") {
477
641
  const enabled = value === "on";
478
642
  updateConfig({ enableCustomFooter: enabled });
479
- customFooterToggle.apply(enabled);
643
+ customFooterSetting.currentValue = enabled ? "on" : "off";
644
+ customFooterSetting.description = customFooterDescription(enabled);
480
645
  if (enabled) applyCustomFooter(ctx);
481
646
  else clearCustomFooter(ctx);
482
647
  ctx.ui.notify(`Updated ${id}: ${value}`, "info");
483
648
  return;
484
649
  }
650
+ if (id === "footerNerdIcons") {
651
+ const enabled = value === "on";
652
+ updateConfig({ footerNerdIcons: enabled });
653
+ footerNerdIconsSetting.currentValue = enabled ? "on" : "off";
654
+ footerNerdIconsSetting.description = enabled
655
+ ? "Git and cache chips use Nerd Font glyphs. Turn off for plain text."
656
+ : "Git and cache chips use plain text. No Nerd Font required.";
657
+ ctx.ui.notify(`Updated ${id}: ${value}`, "info");
658
+ return;
659
+ }
485
660
  // 额外功能开关:字段名与配置布尔字段一一对应,切换后重启生效。
486
661
  const featureToggle = featureToggles[id];
487
662
  if (featureToggle) {
@@ -633,6 +808,30 @@ export async function showCcstylePanel(
633
808
  label: "Style",
634
809
  items: [modeSetting, excludeSetting],
635
810
  },
811
+ {
812
+ id: "feature",
813
+ label: "Features",
814
+ items: [
815
+ sessionReferenceToggle.setting,
816
+ subagentAutocompleteToggle.setting,
817
+ contextCommandToggle.setting,
818
+ agentSummaryToggle.setting,
819
+ workingMessageToggle.setting,
820
+ aliasesToggle.setting,
821
+ ],
822
+ },
823
+ {
824
+ id: "ui",
825
+ label: "UI",
826
+ items: [
827
+ expandedInputSetting,
828
+ expandedOutputSetting,
829
+ expandedMaxSetting,
830
+ inputClipSetting,
831
+ startupHeaderSetting,
832
+ scrollStepSetting,
833
+ ],
834
+ },
636
835
  {
637
836
  id: "diff",
638
837
  label: "Diff",
@@ -656,29 +855,9 @@ export async function showCcstylePanel(
656
855
  ],
657
856
  },
658
857
  {
659
- id: "ui",
660
- label: "UI",
661
- items: [
662
- expandedInputSetting,
663
- expandedOutputSetting,
664
- expandedMaxSetting,
665
- inputClipSetting,
666
- startupHeaderSetting,
667
- scrollStepSetting,
668
- ],
669
- },
670
- {
671
- id: "feature",
672
- label: "Feature",
673
- items: [
674
- sessionReferenceToggle.setting,
675
- subagentAutocompleteToggle.setting,
676
- contextCommandToggle.setting,
677
- agentSummaryToggle.setting,
678
- workingMessageToggle.setting,
679
- aliasesToggle.setting,
680
- customFooterToggle.setting,
681
- ],
858
+ id: "footer",
859
+ label: "Footer",
860
+ items: [customFooterSetting, footerNerdIconsSetting, pluginChipsSetting],
682
861
  },
683
862
  ];
684
863
 
@@ -699,7 +878,7 @@ export async function showCcstylePanel(
699
878
  const activeList = () => lists[activeSection]!;
700
879
 
701
880
  const switchSection = (delta: number) => {
702
- if (excludeSubmenuOpen) return;
881
+ if (nestedSubmenuOpen) return;
703
882
  activeSection = (activeSection + delta + sections.length) % sections.length;
704
883
  };
705
884
 
@@ -749,7 +928,8 @@ export async function showCcstylePanel(
749
928
  truncateToWidth(
750
929
  theme.fg(
751
930
  "dim",
752
- " Enter/Space to change · Enter on numbers types a custom value · Tab/Shift+Tab to switch sections · Esc to close",
931
+ nestedHint ||
932
+ " Enter/Space to change · Enter on numbers types a custom value · Tab/Shift+Tab to switch sections · Esc to close",
753
933
  ),
754
934
  safeWidth,
755
935
  ),
@@ -760,12 +940,12 @@ export async function showCcstylePanel(
760
940
  for (const list of lists) list.invalidate();
761
941
  },
762
942
  handleInput(data: string) {
763
- if (!excludeSubmenuOpen && isForwardTabKey(data)) {
943
+ if (!nestedSubmenuOpen && isForwardTabKey(data)) {
764
944
  switchSection(1);
765
945
  tui.requestRender();
766
946
  return;
767
947
  }
768
- if (!excludeSubmenuOpen && isBackTabKey(data)) {
948
+ if (!nestedSubmenuOpen && isBackTabKey(data)) {
769
949
  switchSection(-1);
770
950
  tui.requestRender();
771
951
  return;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 自定义 footer 插件芯片布局:行、顺序、隐藏。
3
+ * 纯函数,供 config 规范化、footer 渲染、配置面板共用。
4
+ */
5
+
6
+ export const PI_USAGE_KEY = "pi-usage";
7
+ export const PI_USAGE_SOURCE = "@narumitw/pi-usage";
8
+ const LEGACY_PI_USAGE_KEYS = new Set(["localUsage", "piUsage"]);
9
+ export const SKIP_FOOTER_STATUS_KEY = "model";
10
+
11
+ export type FooterLineId = 1 | 2 | 3;
12
+
13
+ export type FooterChipLayout = {
14
+ footerHiddenKeys: string[];
15
+ footerLine1Keys: string[];
16
+ footerLine2Keys: string[];
17
+ footerLine3Keys: string[];
18
+ };
19
+
20
+ export const DEFAULT_FOOTER_CHIP_LAYOUT: FooterChipLayout = {
21
+ footerHiddenKeys: [],
22
+ footerLine1Keys: [PI_USAGE_KEY],
23
+ footerLine2Keys: [],
24
+ footerLine3Keys: [],
25
+ };
26
+
27
+ export function isSkippedFooterStatusKey(key: string): boolean {
28
+ return key === SKIP_FOOTER_STATUS_KEY;
29
+ }
30
+
31
+ /** 未单独配置时的默认行:usage 类和本包用量在 line1,其余 line2。不会默认进 line3。 */
32
+ export function defaultFooterLine(key: string): FooterLineId {
33
+ if (key === PI_USAGE_KEY) return 1;
34
+ if (/usage|quota|balance/i.test(key)) return 1;
35
+ return 2;
36
+ }
37
+
38
+ export function normalizeFooterKeyList(value: unknown): string[] {
39
+ if (!Array.isArray(value)) return [];
40
+ const out: string[] = [];
41
+ const seen = new Set<string>();
42
+ for (const item of value) {
43
+ if (typeof item !== "string") continue;
44
+ let key = item.trim();
45
+ if (LEGACY_PI_USAGE_KEYS.has(key)) key = PI_USAGE_KEY;
46
+ if (!key || isSkippedFooterStatusKey(key) || seen.has(key)) continue;
47
+ seen.add(key);
48
+ out.push(key);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ function takeUnique(keys: string[], seen: Set<string>): string[] {
54
+ const out: string[] = [];
55
+ for (const key of keys) {
56
+ if (seen.has(key)) continue;
57
+ seen.add(key);
58
+ out.push(key);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /** 跨行去重(line1 → line2 → line3);补上 pi-usage 槽位。 */
64
+ export function normalizeFooterChipLayout(source: Record<string, unknown>): FooterChipLayout {
65
+ const hasHidden = Array.isArray(source.footerHiddenKeys);
66
+ const hasAnyLine = [source.footerLine1Keys, source.footerLine2Keys, source.footerLine3Keys].some(
67
+ Array.isArray,
68
+ );
69
+ const seen = new Set<string>();
70
+ let footerLine1Keys = takeUnique(normalizeFooterKeyList(source.footerLine1Keys), seen);
71
+ const footerLine2Keys = takeUnique(normalizeFooterKeyList(source.footerLine2Keys), seen);
72
+ const footerLine3Keys = takeUnique(normalizeFooterKeyList(source.footerLine3Keys), seen);
73
+ const footerHiddenKeys = normalizeFooterKeyList(source.footerHiddenKeys);
74
+
75
+ if (!seen.has(PI_USAGE_KEY)) {
76
+ footerLine1Keys = [PI_USAGE_KEY, ...footerLine1Keys];
77
+ }
78
+ if (!hasHidden && !hasAnyLine) {
79
+ return { ...DEFAULT_FOOTER_CHIP_LAYOUT };
80
+ }
81
+ return { footerHiddenKeys, footerLine1Keys, footerLine2Keys, footerLine3Keys };
82
+ }
83
+
84
+ /** 面板描述用:pi-usage 数据来自 @narumitw/pi-usage,其它芯片用来源 setStatus key。 */
85
+ export function footerChipSource(key: string): string {
86
+ return key === PI_USAGE_KEY ? PI_USAGE_SOURCE : key;
87
+ }
88
+
89
+ export function footerChipDescription(key: string, liveText: string): string {
90
+ const source = footerChipSource(key);
91
+ const live = liveText.trim();
92
+ if (live) return `${live} · from ${source}`;
93
+ if (key === PI_USAGE_KEY) return `From ${source}. No text right now.`;
94
+ return `inactive · from ${source}`;
95
+ }
96
+
97
+ export function footerLineOfKey(layout: FooterChipLayout, key: string): FooterLineId | undefined {
98
+ if (layout.footerLine1Keys.includes(key)) return 1;
99
+ if (layout.footerLine2Keys.includes(key)) return 2;
100
+ if (layout.footerLine3Keys.includes(key)) return 3;
101
+ return undefined;
102
+ }
103
+
104
+ function keysOnLine(layout: FooterChipLayout, line: FooterLineId): string[] {
105
+ if (line === 1) return layout.footerLine1Keys;
106
+ if (line === 2) return layout.footerLine2Keys;
107
+ return layout.footerLine3Keys;
108
+ }
109
+
110
+ function withLineKeys(
111
+ layout: FooterChipLayout,
112
+ line: FooterLineId,
113
+ keys: string[],
114
+ ): FooterChipLayout {
115
+ if (line === 1) return { ...layout, footerLine1Keys: keys };
116
+ if (line === 2) return { ...layout, footerLine2Keys: keys };
117
+ return { ...layout, footerLine3Keys: keys };
118
+ }
119
+
120
+ /**
121
+ * 配置里还没有的 live key 接到默认行末尾(按 key 字母序,避免每次渲染乱序)。
122
+ * pi-usage 由 normalize 保证已在某一行。
123
+ */
124
+ export function resolveFooterChipLayout(
125
+ layout: FooterChipLayout,
126
+ liveKeys: readonly string[],
127
+ ): FooterChipLayout {
128
+ const known = new Set([
129
+ ...layout.footerLine1Keys,
130
+ ...layout.footerLine2Keys,
131
+ ...layout.footerLine3Keys,
132
+ ]);
133
+ const extras = [
134
+ ...new Set(liveKeys.filter((key) => key && !isSkippedFooterStatusKey(key) && !known.has(key))),
135
+ ].sort((a, b) => a.localeCompare(b));
136
+ const next: FooterChipLayout = {
137
+ footerHiddenKeys: [...layout.footerHiddenKeys],
138
+ footerLine1Keys: [...layout.footerLine1Keys],
139
+ footerLine2Keys: [...layout.footerLine2Keys],
140
+ footerLine3Keys: [...layout.footerLine3Keys],
141
+ };
142
+ for (const key of extras) {
143
+ if (defaultFooterLine(key) === 1) next.footerLine1Keys.push(key);
144
+ else next.footerLine2Keys.push(key);
145
+ }
146
+ return next;
147
+ }
148
+
149
+ export function moveFooterKeyToLine(
150
+ layout: FooterChipLayout,
151
+ key: string,
152
+ line: FooterLineId,
153
+ ): FooterChipLayout {
154
+ if (isSkippedFooterStatusKey(key)) return layout;
155
+ const current = footerLineOfKey(layout, key);
156
+ if (current === line) return layout;
157
+ const stripped: FooterChipLayout = {
158
+ footerHiddenKeys: [...layout.footerHiddenKeys],
159
+ footerLine1Keys: layout.footerLine1Keys.filter((item) => item !== key),
160
+ footerLine2Keys: layout.footerLine2Keys.filter((item) => item !== key),
161
+ footerLine3Keys: layout.footerLine3Keys.filter((item) => item !== key),
162
+ };
163
+ return withLineKeys(stripped, line, [...keysOnLine(stripped, line), key]);
164
+ }
165
+
166
+ export function shiftFooterKeyLine(
167
+ layout: FooterChipLayout,
168
+ key: string,
169
+ delta: -1 | 1,
170
+ ): FooterChipLayout {
171
+ const current = footerLineOfKey(layout, key);
172
+ if (!current) return layout;
173
+ const next = (current + delta) as number;
174
+ if (next < 1 || next > 3) return layout;
175
+ return moveFooterKeyToLine(layout, key, next as FooterLineId);
176
+ }
177
+
178
+ export function reorderFooterKey(
179
+ layout: FooterChipLayout,
180
+ key: string,
181
+ delta: -1 | 1,
182
+ ): FooterChipLayout {
183
+ const line = footerLineOfKey(layout, key);
184
+ if (!line) return layout;
185
+ const keys = [...keysOnLine(layout, line)];
186
+ const index = keys.indexOf(key);
187
+ const nextIndex = index + delta;
188
+ if (index < 0 || nextIndex < 0 || nextIndex >= keys.length) return layout;
189
+ const swap = keys[nextIndex]!;
190
+ keys[nextIndex] = key;
191
+ keys[index] = swap;
192
+ return withLineKeys(layout, line, keys);
193
+ }
194
+
195
+ export function toggleFooterKeyHidden(layout: FooterChipLayout, key: string): FooterChipLayout {
196
+ if (isSkippedFooterStatusKey(key)) return layout;
197
+ const hidden = new Set(layout.footerHiddenKeys);
198
+ if (hidden.has(key)) hidden.delete(key);
199
+ else hidden.add(key);
200
+ return { ...layout, footerHiddenKeys: [...hidden] };
201
+ }
202
+
203
+ /** 按行顺序收集可见、且当前有文案的插件芯片文本。 */
204
+ export function visibleFooterPluginTexts(
205
+ lineKeys: readonly string[],
206
+ hiddenKeys: readonly string[],
207
+ texts: ReadonlyMap<string, string>,
208
+ ): string[] {
209
+ const hidden = new Set(hiddenKeys);
210
+ const out: string[] = [];
211
+ for (const key of lineKeys) {
212
+ if (hidden.has(key)) continue;
213
+ const text = texts
214
+ .get(key)
215
+ ?.replace(/[\r\n\t]+/g, " ")
216
+ .trim();
217
+ if (text) out.push(text);
218
+ }
219
+ return out;
220
+ }
221
+
222
+ export function formatFooterChipSummary(layout: FooterChipLayout): string {
223
+ const hidden = layout.footerHiddenKeys.length;
224
+ return `hidden=${hidden} · L1=${layout.footerLine1Keys.length} · L2=${layout.footerLine2Keys.length} · L3=${layout.footerLine3Keys.length}`;
225
+ }
226
+
227
+ export function orderedFooterKeys(layout: FooterChipLayout): string[] {
228
+ return [...layout.footerLine1Keys, ...layout.footerLine2Keys, ...layout.footerLine3Keys];
229
+ }