@pi-unipi/footer 0.1.3 → 0.1.4
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 +73 -158
- package/package.json +1 -1
- package/src/commands.ts +36 -121
- package/src/config.ts +4 -0
- package/src/help.ts +160 -0
- package/src/index.ts +25 -1
- package/src/presets.ts +40 -31
- package/src/rendering/icons.ts +38 -20
- package/src/rendering/renderer.ts +195 -76
- package/src/rendering/theme.ts +56 -29
- package/src/segments/compactor.ts +21 -10
- package/src/segments/core.ts +122 -14
- package/src/segments/kanboard.ts +24 -8
- package/src/segments/mcp.ts +25 -8
- package/src/segments/memory.ts +8 -4
- package/src/segments/notify.ts +16 -5
- package/src/segments/ralph.ts +24 -7
- package/src/segments/status-ext.ts +1 -1
- package/src/segments/workflow.ts +39 -17
- package/src/tps-tracker.ts +204 -0
- package/src/tui/settings-tui.ts +228 -57
- package/src/types.ts +51 -12
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { STATUS_EXT_SEGMENTS } from "./segments/status-ext.js";
|
|
|
27
27
|
|
|
28
28
|
import type { FooterGroup, FooterSegment } from "./types.js";
|
|
29
29
|
import { rainbowBorder } from "./segments/core.js";
|
|
30
|
+
import { tpsTracker } from "./tps-tracker.js";
|
|
30
31
|
|
|
31
32
|
/** All segment groups */
|
|
32
33
|
const ALL_GROUPS: FooterGroup[] = [
|
|
@@ -124,6 +125,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
124
125
|
state.refreshTimer = null;
|
|
125
126
|
}
|
|
126
127
|
state.tuiRef = null;
|
|
128
|
+
tpsTracker.reset();
|
|
127
129
|
});
|
|
128
130
|
|
|
129
131
|
// ─── Register commands ──────────────────────────────────────────────────
|
|
@@ -136,7 +138,7 @@ export default function footerExtension(pi: ExtensionAPI): void {
|
|
|
136
138
|
emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
|
|
137
139
|
name: "@pi-unipi/footer",
|
|
138
140
|
version: "0.1.0",
|
|
139
|
-
commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`],
|
|
141
|
+
commands: [`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`, `${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_HELP}`],
|
|
140
142
|
tools: [],
|
|
141
143
|
});
|
|
142
144
|
});
|
|
@@ -152,6 +154,28 @@ function setupFooterUI(pi: ExtensionAPI, ctx: any, state: FooterState): void {
|
|
|
152
154
|
// Start periodic refresh for time-sensitive segments (e.g. clock)
|
|
153
155
|
if (!state.refreshTimer) {
|
|
154
156
|
state.refreshTimer = setInterval(() => {
|
|
157
|
+
// Feed TPS tracker with per-message data
|
|
158
|
+
try {
|
|
159
|
+
const piCtx = state.piContext as Record<string, unknown> | undefined;
|
|
160
|
+
if (piCtx?.sessionManager) {
|
|
161
|
+
const sm = (piCtx as any).sessionManager;
|
|
162
|
+
const events = sm?.getBranch?.() ?? [];
|
|
163
|
+
let msgIndex = 0;
|
|
164
|
+
for (const e of events) {
|
|
165
|
+
if (!e || typeof e !== "object") continue;
|
|
166
|
+
if (e.type !== "message") continue;
|
|
167
|
+
const m = e.message;
|
|
168
|
+
if (!m || m.role !== "assistant") continue;
|
|
169
|
+
if (m.stopReason === "error" || m.stopReason === "aborted") continue;
|
|
170
|
+
const output = m.usage?.output ?? 0;
|
|
171
|
+
const hasStop = !!m.stopReason;
|
|
172
|
+
tpsTracker.onMessageUpdate(msgIndex, output, hasStop);
|
|
173
|
+
msgIndex++;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// Silently ignore — TPS is best-effort
|
|
178
|
+
}
|
|
155
179
|
state.renderer.resetLayoutCache();
|
|
156
180
|
state.tuiRef?.requestRender();
|
|
157
181
|
}, 1_000);
|
package/src/presets.ts
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* @pi-unipi/footer — Presets system
|
|
3
3
|
*
|
|
4
4
|
* Preset definitions: default, minimal, compact, full, nerd, ascii.
|
|
5
|
-
* Each preset defines which segments appear
|
|
5
|
+
* Each preset defines which segments appear (left/center/right/secondary),
|
|
6
6
|
* plus separator style and color scheme.
|
|
7
|
+
*
|
|
8
|
+
* Segments are grouped by their zone field regardless of which array they're
|
|
9
|
+
* listed in. The arrays define ordering within the preset.
|
|
7
10
|
*/
|
|
8
11
|
|
|
9
12
|
import type { PresetDef, SeparatorStyle, ColorScheme } from "./types.js";
|
|
@@ -12,100 +15,106 @@ import { getDefaultColors } from "./rendering/theme.js";
|
|
|
12
15
|
/** Default preset — balanced view */
|
|
13
16
|
const DEFAULT_PRESET: PresetDef = {
|
|
14
17
|
leftSegments: [
|
|
15
|
-
"model", "api_state", "tool_count", "git",
|
|
18
|
+
"model", "api_state", "tool_count", "git",
|
|
16
19
|
],
|
|
17
20
|
rightSegments: [
|
|
18
|
-
"
|
|
21
|
+
"tps", "context_pct", "cost",
|
|
22
|
+
"compactions", "tokens_saved", "project_count",
|
|
23
|
+
"current_command", "loop_status", "extension_statuses",
|
|
24
|
+
"clock", "duration",
|
|
19
25
|
],
|
|
20
26
|
secondarySegments: [
|
|
21
|
-
"
|
|
27
|
+
"session",
|
|
22
28
|
],
|
|
23
|
-
separator: "powerline-thin",
|
|
24
29
|
colors: getDefaultColors(),
|
|
25
30
|
};
|
|
26
31
|
|
|
27
32
|
/** Minimal preset — just the essentials */
|
|
28
33
|
const MINIMAL_PRESET: PresetDef = {
|
|
29
34
|
leftSegments: [
|
|
30
|
-
"
|
|
35
|
+
"model", "git",
|
|
36
|
+
],
|
|
37
|
+
rightSegments: [
|
|
38
|
+
"context_pct",
|
|
39
|
+
"clock",
|
|
31
40
|
],
|
|
32
|
-
rightSegments: [],
|
|
33
41
|
secondarySegments: [],
|
|
34
|
-
separator: "pipe",
|
|
35
42
|
colors: getDefaultColors(),
|
|
36
43
|
};
|
|
37
44
|
|
|
38
45
|
/** Compact preset — core + key stats */
|
|
39
46
|
const COMPACT_PRESET: PresetDef = {
|
|
40
47
|
leftSegments: [
|
|
41
|
-
"model", "git",
|
|
48
|
+
"model", "git",
|
|
42
49
|
],
|
|
43
50
|
rightSegments: [
|
|
44
|
-
"
|
|
51
|
+
"tps", "context_pct", "cost",
|
|
52
|
+
"clock", "duration",
|
|
45
53
|
],
|
|
46
54
|
secondarySegments: [],
|
|
47
|
-
separator: "dot",
|
|
48
55
|
colors: getDefaultColors(),
|
|
49
56
|
};
|
|
50
57
|
|
|
51
58
|
/** Full preset — everything */
|
|
52
59
|
const FULL_PRESET: PresetDef = {
|
|
53
60
|
leftSegments: [
|
|
54
|
-
"model", "api_state", "tool_count", "git", "
|
|
55
|
-
"tokens_total", "tokens_in", "tokens_out",
|
|
61
|
+
"model", "api_state", "tool_count", "git", "current_command", "session",
|
|
56
62
|
],
|
|
57
63
|
rightSegments: [
|
|
58
|
-
"
|
|
59
|
-
"
|
|
60
|
-
"project_count", "total_count",
|
|
61
|
-
"servers_total", "servers_active", "tools_total",
|
|
62
|
-
"active_loops", "
|
|
63
|
-
"
|
|
64
|
-
"docs_count", "tasks_done", "tasks_total", "task_pct",
|
|
64
|
+
"tps", "context_pct", "cost", "tokens_total",
|
|
65
|
+
"session_events", "compactions", "tokens_saved",
|
|
66
|
+
"project_count", "total_count",
|
|
67
|
+
"servers_total", "servers_active", "tools_total",
|
|
68
|
+
"active_loops", "loop_status",
|
|
69
|
+
"docs_count", "tasks_done", "task_pct",
|
|
65
70
|
"extension_statuses",
|
|
71
|
+
"clock", "duration",
|
|
66
72
|
],
|
|
67
73
|
secondarySegments: [
|
|
68
|
-
"hostname",
|
|
74
|
+
"hostname",
|
|
75
|
+
"tokens_in", "tokens_out",
|
|
76
|
+
"compression_ratio", "indexed_docs",
|
|
69
77
|
"platforms_enabled", "last_sent",
|
|
78
|
+
"thinking_level",
|
|
70
79
|
],
|
|
71
|
-
separator: "powerline-thin",
|
|
72
80
|
colors: getDefaultColors(),
|
|
73
81
|
};
|
|
74
82
|
|
|
75
83
|
/** Nerd preset — maximum detail for Nerd Font users */
|
|
76
84
|
const NERD_PRESET: PresetDef = {
|
|
77
85
|
leftSegments: [
|
|
78
|
-
"model", "api_state", "tool_count", "git", "
|
|
79
|
-
"tokens_total",
|
|
86
|
+
"model", "api_state", "tool_count", "git", "current_command", "session",
|
|
80
87
|
],
|
|
81
88
|
rightSegments: [
|
|
89
|
+
"tps", "context_pct", "cost", "tokens_total",
|
|
82
90
|
"session_events", "compactions", "tokens_saved",
|
|
83
91
|
"project_count", "total_count",
|
|
84
92
|
"servers_total", "servers_active", "tools_total",
|
|
85
93
|
"active_loops", "loop_status",
|
|
86
|
-
"
|
|
87
|
-
"docs_count", "tasks_done", "tasks_total", "task_pct",
|
|
94
|
+
"docs_count", "tasks_done", "task_pct",
|
|
88
95
|
"extension_statuses",
|
|
96
|
+
"clock", "duration",
|
|
89
97
|
],
|
|
90
98
|
secondarySegments: [
|
|
91
|
-
"
|
|
99
|
+
"hostname",
|
|
100
|
+
"tokens_in", "tokens_out",
|
|
92
101
|
"compression_ratio", "indexed_docs",
|
|
93
102
|
"platforms_enabled", "last_sent",
|
|
103
|
+
"thinking_level",
|
|
94
104
|
],
|
|
95
|
-
separator: "powerline",
|
|
96
105
|
colors: getDefaultColors(),
|
|
97
106
|
};
|
|
98
107
|
|
|
99
108
|
/** ASCII preset — safe for any terminal */
|
|
100
109
|
const ASCII_PRESET: PresetDef = {
|
|
101
110
|
leftSegments: [
|
|
102
|
-
"model", "git",
|
|
111
|
+
"model", "git",
|
|
103
112
|
],
|
|
104
113
|
rightSegments: [
|
|
105
|
-
"
|
|
114
|
+
"tps", "context_pct", "cost",
|
|
115
|
+
"clock", "duration",
|
|
106
116
|
],
|
|
107
117
|
secondarySegments: [],
|
|
108
|
-
separator: "ascii",
|
|
109
118
|
colors: getDefaultColors(),
|
|
110
119
|
};
|
|
111
120
|
|
package/src/rendering/icons.ts
CHANGED
|
@@ -27,6 +27,10 @@ export interface IconSet {
|
|
|
27
27
|
session: string;
|
|
28
28
|
hostname: string;
|
|
29
29
|
time: string;
|
|
30
|
+
tps: string;
|
|
31
|
+
clock: string;
|
|
32
|
+
duration: string;
|
|
33
|
+
thinkingLevel: string;
|
|
30
34
|
|
|
31
35
|
// Compactor segments
|
|
32
36
|
sessionEvents: string;
|
|
@@ -92,6 +96,10 @@ export const NERD_ICONS: IconSet = {
|
|
|
92
96
|
session: "\uF550", // nf-md-identifier
|
|
93
97
|
hostname: "\uF109", // nf-fa-laptop
|
|
94
98
|
time: "\uF017", // nf-fa-clock_o
|
|
99
|
+
tps: "\uF062", // \u2191 up arrow
|
|
100
|
+
clock: "\uF017", // nf-fa-clock_o
|
|
101
|
+
duration: "\uF49B", // nf-md-timer_outline
|
|
102
|
+
thinkingLevel: "\uF4D8", // nf-fa-lightbulb_o
|
|
95
103
|
|
|
96
104
|
// Compactor
|
|
97
105
|
sessionEvents: "\uDBB1\uDECF", // session events icon
|
|
@@ -147,33 +155,38 @@ export const EMOJI_ICONS: IconSet = {
|
|
|
147
155
|
model: "🤖",
|
|
148
156
|
apiState: "🔄",
|
|
149
157
|
toolCount: "🔧",
|
|
150
|
-
git: "
|
|
158
|
+
git: "🔀",
|
|
151
159
|
context: "🗄️",
|
|
152
160
|
cost: "💲",
|
|
153
|
-
tokens: "
|
|
154
|
-
tokensIn: "
|
|
155
|
-
tokensOut: "
|
|
156
|
-
session: "
|
|
157
|
-
hostname: "
|
|
161
|
+
tokens: "📊",
|
|
162
|
+
tokensIn: "⬇️",
|
|
163
|
+
tokensOut: "⬆️",
|
|
164
|
+
session: "📋",
|
|
165
|
+
hostname: "🏠",
|
|
158
166
|
time: "⏱",
|
|
159
167
|
|
|
168
|
+
tps: "⚡",
|
|
169
|
+
clock: "🕔",
|
|
170
|
+
duration: "⏱",
|
|
171
|
+
thinkingLevel: "💡",
|
|
172
|
+
|
|
160
173
|
// Compactor
|
|
161
|
-
sessionEvents: "
|
|
162
|
-
compactions: "
|
|
174
|
+
sessionEvents: "📈",
|
|
175
|
+
compactions: "🗜️",
|
|
163
176
|
tokensSaved: "💲",
|
|
164
|
-
compressionRatio:"
|
|
165
|
-
indexedDocs: "
|
|
166
|
-
sandboxRuns: "
|
|
167
|
-
searchQueries: "
|
|
177
|
+
compressionRatio:"📐",
|
|
178
|
+
indexedDocs: "📑",
|
|
179
|
+
sandboxRuns: "▶️",
|
|
180
|
+
searchQueries: "🔍",
|
|
168
181
|
|
|
169
182
|
// Memory
|
|
170
183
|
projectCount: "🧠",
|
|
171
184
|
totalCount: "🧠",
|
|
172
|
-
consolidations: "
|
|
185
|
+
consolidations: "🔄",
|
|
173
186
|
|
|
174
187
|
// MCP
|
|
175
188
|
serversTotal: "🖥️",
|
|
176
|
-
serversActive: "
|
|
189
|
+
serversActive: "🟢",
|
|
177
190
|
toolsTotal: "🔧",
|
|
178
191
|
serversFailed: "⚠️",
|
|
179
192
|
|
|
@@ -188,17 +201,17 @@ export const EMOJI_ICONS: IconSet = {
|
|
|
188
201
|
commandDuration: "⏱",
|
|
189
202
|
|
|
190
203
|
// Kanboard
|
|
191
|
-
docsCount: "
|
|
192
|
-
tasksDone: "
|
|
193
|
-
tasksTotal: "
|
|
194
|
-
taskPct: "
|
|
204
|
+
docsCount: "📑",
|
|
205
|
+
tasksDone: "✅",
|
|
206
|
+
tasksTotal: "📋",
|
|
207
|
+
taskPct: "📊",
|
|
195
208
|
|
|
196
209
|
// Notify
|
|
197
|
-
platformsEnabled:"
|
|
210
|
+
platformsEnabled:"🔔",
|
|
198
211
|
lastSent: "⏱",
|
|
199
212
|
|
|
200
213
|
// Extension status
|
|
201
|
-
extensionStatuses:"
|
|
214
|
+
extensionStatuses:"🧩",
|
|
202
215
|
|
|
203
216
|
separator: "|",
|
|
204
217
|
};
|
|
@@ -221,6 +234,11 @@ export const TEXT_ICONS: IconSet = {
|
|
|
221
234
|
hostname: "HST",
|
|
222
235
|
time: "TIM",
|
|
223
236
|
|
|
237
|
+
tps: "TPS",
|
|
238
|
+
clock: "CLK",
|
|
239
|
+
duration: "DUR",
|
|
240
|
+
thinkingLevel: "THK",
|
|
241
|
+
|
|
224
242
|
// Compactor
|
|
225
243
|
sessionEvents: "EVT",
|
|
226
244
|
compactions: "CMP",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Theme } from "@mariozechner/pi-coding-agent";
|
|
10
|
-
import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, RenderedSegment } from "../types.js";
|
|
10
|
+
import type { PresetDef, FooterSegmentContext, FooterSegment, ColorScheme, RenderedSegment, SegmentZone } from "../types.js";
|
|
11
11
|
import type { FooterRegistry } from "../registry/index.js";
|
|
12
12
|
import { visibleWidth as piVisibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
13
13
|
import { getSeparator, separatorVisibleWidth } from "./separators.js";
|
|
@@ -143,8 +143,8 @@ export class FooterRenderer {
|
|
|
143
143
|
}
|
|
144
144
|
|
|
145
145
|
/**
|
|
146
|
-
* Compute responsive layout for the given width.
|
|
147
|
-
* Segments
|
|
146
|
+
* Compute responsive zone-based layout for the given width.
|
|
147
|
+
* Segments are grouped by zone (left/center/right) and rendered with alignment.
|
|
148
148
|
*/
|
|
149
149
|
computeLayout(width: number): { topContent: string; secondaryContent: string } {
|
|
150
150
|
// Return cached layout if still valid
|
|
@@ -155,41 +155,45 @@ export class FooterRenderer {
|
|
|
155
155
|
|
|
156
156
|
const presetDef = getPreset(this.presetName);
|
|
157
157
|
const colors = presetDef.colors ?? getDefaultColors();
|
|
158
|
+
const settings = loadFooterSettings();
|
|
159
|
+
const labelMode = settings.showFullLabels ? "labeled" as const : "compact" as const;
|
|
158
160
|
|
|
159
|
-
//
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
...presetDef.rightSegments,
|
|
163
|
-
...presetDef.secondarySegments,
|
|
164
|
-
];
|
|
161
|
+
// Collect all segment IDs from preset
|
|
162
|
+
const primaryIds = [...presetDef.leftSegments, ...presetDef.rightSegments];
|
|
163
|
+
const secondaryIds = [...presetDef.secondarySegments];
|
|
165
164
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
165
|
+
// Render segments grouped by their zone
|
|
166
|
+
const zones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
|
|
167
|
+
left: [],
|
|
168
|
+
center: [],
|
|
169
|
+
right: [],
|
|
170
|
+
};
|
|
171
|
+
const overflowZones: Record<SegmentZone, RenderedSegmentWithWidth[]> = {
|
|
172
|
+
left: [],
|
|
173
|
+
center: [],
|
|
174
|
+
right: [],
|
|
175
|
+
};
|
|
169
176
|
|
|
177
|
+
// Render primary segments and group by zone
|
|
178
|
+
for (const segId of primaryIds) {
|
|
179
|
+
const rendered = this.renderSegment(segId, colors, width, labelMode);
|
|
180
|
+
if (!rendered) continue;
|
|
170
181
|
const segment = this.segmentLookup.get(segId);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const rendered = segment.render(ctx);
|
|
183
|
-
if (!rendered.visible || !rendered.content) continue;
|
|
184
|
-
|
|
185
|
-
renderedSegments.push({
|
|
186
|
-
content: rendered.content,
|
|
187
|
-
width: visibleWidth(rendered.content),
|
|
188
|
-
visible: true,
|
|
189
|
-
});
|
|
182
|
+
const zone = segment?.zone ?? "center";
|
|
183
|
+
zones[zone].push(rendered);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Render secondary segments
|
|
187
|
+
const secondaryRendered: RenderedSegmentWithWidth[] = [];
|
|
188
|
+
for (const segId of secondaryIds) {
|
|
189
|
+
const rendered = this.renderSegment(segId, colors, width, labelMode);
|
|
190
|
+
if (!rendered) continue;
|
|
191
|
+
secondaryRendered.push(rendered);
|
|
190
192
|
}
|
|
191
193
|
|
|
192
|
-
if
|
|
194
|
+
// Check if we have any content
|
|
195
|
+
const totalSegments = zones.left.length + zones.center.length + zones.right.length;
|
|
196
|
+
if (totalSegments === 0 && secondaryRendered.length === 0) {
|
|
193
197
|
this.lastLayoutResult = { topContent: "", secondaryContent: "" };
|
|
194
198
|
this.lastLayoutWidth = width;
|
|
195
199
|
this.lastLayoutTimestamp = now;
|
|
@@ -197,53 +201,46 @@ export class FooterRenderer {
|
|
|
197
201
|
return this.lastLayoutResult;
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
const
|
|
203
|
-
const
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
} else {
|
|
227
|
-
overflow = true;
|
|
228
|
-
overflowParts.push(seg);
|
|
204
|
+
const sepDef = getSeparator(settings.separator);
|
|
205
|
+
const sepWidth = visibleWidth(sepDef.left) + 2;
|
|
206
|
+
const zoneSep = presetDef.zoneSeparator ?? settings.zoneSeparator ?? "\u2502";
|
|
207
|
+
const zoneSepWidth = visibleWidth(zoneSep) + 2; // +2 for spaces around zone sep
|
|
208
|
+
const dimZoneSep = `\x1b[2m${zoneSep}\x1b[0m`; // dimmed zone separator
|
|
209
|
+
|
|
210
|
+
// Calculate widths per zone
|
|
211
|
+
const leftWidth = this.measureZoneWidth(zones.left, sepWidth);
|
|
212
|
+
const rightWidth = this.measureZoneWidth(zones.right, sepWidth);
|
|
213
|
+
const numZoneSeps = (leftWidth > 0 ? 1 : 0) + (rightWidth > 0 ? 1 : 0);
|
|
214
|
+
const availableForCenter = width - leftWidth - rightWidth - numZoneSeps * zoneSepWidth - 2; // -2 for margins
|
|
215
|
+
|
|
216
|
+
// Overflow check: if center doesn't fit, move excess to overflow
|
|
217
|
+
const centerWidth = this.measureZoneWidth(zones.center, sepWidth);
|
|
218
|
+
if (centerWidth > Math.max(0, availableForCenter)) {
|
|
219
|
+
// Move overflow center segments to secondary
|
|
220
|
+
let fitWidth = 0;
|
|
221
|
+
let cutoffIdx = 0;
|
|
222
|
+
for (let i = 0; i < zones.center.length; i++) {
|
|
223
|
+
const needed = zones.center[i].width + (i > 0 ? sepWidth : 0);
|
|
224
|
+
if (fitWidth + needed <= Math.max(0, availableForCenter)) {
|
|
225
|
+
fitWidth += needed;
|
|
226
|
+
cutoffIdx = i + 1;
|
|
227
|
+
} else {
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
229
230
|
}
|
|
231
|
+
const overflow = zones.center.splice(cutoffIdx);
|
|
232
|
+
overflowZones.center.push(...overflow);
|
|
230
233
|
}
|
|
231
234
|
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
let secondaryParts: string[] = [];
|
|
235
|
-
for (const seg of overflowParts) {
|
|
236
|
-
const needed = seg.width + (secondaryParts.length > 0 ? sepWidth : 0);
|
|
237
|
-
if (secondaryWidth + needed <= width) {
|
|
238
|
-
secondaryParts.push(seg.content);
|
|
239
|
-
secondaryWidth += needed;
|
|
240
|
-
} else {
|
|
241
|
-
break; // Stop at first non-fitting segment
|
|
242
|
-
}
|
|
243
|
-
}
|
|
235
|
+
// Build top row with zones
|
|
236
|
+
const topContent = this.buildZoneRow(zones, width, sepDef, dimZoneSep);
|
|
244
237
|
|
|
245
|
-
|
|
246
|
-
const
|
|
238
|
+
// Build secondary row with overflow + preset secondary segments
|
|
239
|
+
const allSecondary = [...overflowZones.center, ...secondaryRendered];
|
|
240
|
+
const secondaryContent = this.buildContentFromParts(
|
|
241
|
+
allSecondary.map(s => s.content),
|
|
242
|
+
sepDef,
|
|
243
|
+
);
|
|
247
244
|
|
|
248
245
|
this.lastLayoutResult = { topContent, secondaryContent };
|
|
249
246
|
this.lastLayoutWidth = width;
|
|
@@ -253,6 +250,128 @@ export class FooterRenderer {
|
|
|
253
250
|
return this.lastLayoutResult;
|
|
254
251
|
}
|
|
255
252
|
|
|
253
|
+
/** Render a single segment by ID, returns null if not visible */
|
|
254
|
+
private renderSegment(
|
|
255
|
+
segId: string,
|
|
256
|
+
colors: ColorScheme,
|
|
257
|
+
fullWidth: number,
|
|
258
|
+
labelMode: "compact" | "labeled",
|
|
259
|
+
): RenderedSegmentWithWidth | null {
|
|
260
|
+
if (!isSegmentEnabled(this.getGroupForSegment(segId), segId)) return null;
|
|
261
|
+
|
|
262
|
+
const segment = this.segmentLookup.get(segId);
|
|
263
|
+
if (!segment) return null;
|
|
264
|
+
|
|
265
|
+
const ctx: FooterSegmentContext = {
|
|
266
|
+
theme: this.getThemeLike(),
|
|
267
|
+
colors,
|
|
268
|
+
data: this.registry.getGroupData(this.getGroupForSegment(segId)),
|
|
269
|
+
width: fullWidth,
|
|
270
|
+
piContext: this.piContext,
|
|
271
|
+
footerData: this.footerData,
|
|
272
|
+
labelMode,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const rendered = segment.render(ctx);
|
|
276
|
+
if (!rendered.visible || !rendered.content) return null;
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
content: rendered.content,
|
|
280
|
+
width: visibleWidth(rendered.content),
|
|
281
|
+
visible: true,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Measure total width of a zone's rendered segments */
|
|
286
|
+
private measureZoneWidth(segments: RenderedSegmentWithWidth[], sepWidth: number): number {
|
|
287
|
+
if (segments.length === 0) return 0;
|
|
288
|
+
let total = 0;
|
|
289
|
+
for (let i = 0; i < segments.length; i++) {
|
|
290
|
+
total += segments[i].width + (i > 0 ? sepWidth : 0);
|
|
291
|
+
}
|
|
292
|
+
return total;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Build a zone-based row string */
|
|
296
|
+
private buildZoneRow(
|
|
297
|
+
zones: Record<SegmentZone, RenderedSegmentWithWidth[]>,
|
|
298
|
+
fullWidth: number,
|
|
299
|
+
sepDef: { left: string },
|
|
300
|
+
dimZoneSep: string,
|
|
301
|
+
): string {
|
|
302
|
+
const parts: string[] = [];
|
|
303
|
+
|
|
304
|
+
// Left zone
|
|
305
|
+
const leftContent = this.buildContentFromPartsRaw(
|
|
306
|
+
zones.left.map(s => s.content),
|
|
307
|
+
sepDef,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
// Center zone
|
|
311
|
+
const centerContent = this.buildContentFromPartsRaw(
|
|
312
|
+
zones.center.map(s => s.content),
|
|
313
|
+
sepDef,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// Right zone
|
|
317
|
+
const rightContent = this.buildContentFromPartsRaw(
|
|
318
|
+
zones.right.map(s => s.content),
|
|
319
|
+
sepDef,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Assemble zones with alignment
|
|
323
|
+
const leftWidth = zones.left.length > 0 ? this.measureZoneWidth(zones.left, visibleWidth(sepDef.left) + 2) : 0;
|
|
324
|
+
const rightWidth = zones.right.length > 0 ? this.measureZoneWidth(zones.right, visibleWidth(sepDef.left) + 2) : 0;
|
|
325
|
+
|
|
326
|
+
// Simple case: no zones → return empty
|
|
327
|
+
if (!leftContent && !centerContent && !rightContent) return "";
|
|
328
|
+
|
|
329
|
+
// Build with zone separators
|
|
330
|
+
let result = " "; // leading margin
|
|
331
|
+
|
|
332
|
+
if (leftContent) {
|
|
333
|
+
result += leftContent;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (centerContent) {
|
|
337
|
+
if (leftContent) result += ` ${dimZoneSep} `;
|
|
338
|
+
result += centerContent;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (rightContent) {
|
|
342
|
+
const currentLen = visibleWidth(result);
|
|
343
|
+
const rightStart = fullWidth - rightWidth - 1; // -1 for trailing margin
|
|
344
|
+
const gap = rightStart - currentLen;
|
|
345
|
+
|
|
346
|
+
if (gap > 0) {
|
|
347
|
+
// Pad to right-align the right zone
|
|
348
|
+
result += " ".repeat(gap);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (centerContent || leftContent) {
|
|
352
|
+
// Only add zone separator if there's content before it
|
|
353
|
+
if (gap > visibleWidth(dimZoneSep) + 2) {
|
|
354
|
+
// Place zone sep right before right content
|
|
355
|
+
const sepPos = result.length - gap + Math.floor((gap - visibleWidth(dimZoneSep)) / 2);
|
|
356
|
+
// Simpler: just put it at the boundary
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
result += rightContent;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
result += " "; // trailing margin
|
|
364
|
+
return result;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Build content from parts array (raw strings) */
|
|
368
|
+
private buildContentFromPartsRaw(parts: string[], sepDef: { left: string }): string {
|
|
369
|
+
if (parts.length === 0) return "";
|
|
370
|
+
const sep = sepDef.left;
|
|
371
|
+
const sepAnsi = getFgAnsiCode(getPreset(this.presetName).colors ?? getDefaultColors(), "separator");
|
|
372
|
+
return parts.join(` ${sepAnsi}${sep}${ANSI_RESET} `);
|
|
373
|
+
}
|
|
374
|
+
|
|
256
375
|
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
257
376
|
|
|
258
377
|
private buildContentFromParts(parts: string[], sepDef: { left: string }): string {
|
|
@@ -265,7 +384,7 @@ export class FooterRenderer {
|
|
|
265
384
|
/** Map a segment ID to its group ID */
|
|
266
385
|
private getGroupForSegment(segId: string): string {
|
|
267
386
|
// Core segments
|
|
268
|
-
const coreIds = ["model", "api_state", "tool_count", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time"];
|
|
387
|
+
const coreIds = ["model", "api_state", "tool_count", "git", "context_pct", "cost", "tokens_total", "tokens_in", "tokens_out", "session", "hostname", "time", "tps", "clock", "duration", "thinking_level"];
|
|
269
388
|
if (coreIds.includes(segId)) return "core";
|
|
270
389
|
|
|
271
390
|
// Compactor segments
|