@underactive/pi-topping-statusline 0.1.3 → 0.2.0
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 +19 -4
- package/index.ts +154 -20
- package/package.json +5 -4
- package/src/box.ts +15 -0
- package/src/context.ts +96 -8
- package/src/footer.ts +46 -0
- package/src/layout.ts +39 -8
- package/src/segments.ts +28 -4
- package/src/settings-menu.ts +13 -3
- package/src/settings.ts +12 -0
- package/src/tests/box.test.ts +14 -1
- package/src/tests/feeds.test.ts +165 -16
- package/src/tests/footer.test.ts +30 -1
- package/src/tests/working.test.ts +140 -0
- package/src/theme.ts +54 -11
- package/src/types.ts +23 -1
- package/src/utils.ts +7 -5
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts for the opt-in embedded working indicator: the `working` segment
|
|
3
|
+
* passes pi's pre-rendered status through verbatim, the top-left group swaps
|
|
4
|
+
* everything but the Pi symbol for it while a stream is live, and the layout
|
|
5
|
+
* truncates it (no ellipsis) before any other left segment is dropped.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { buildStatusLine } from "../layout.ts";
|
|
11
|
+
import { SEGMENTS } from "../segments.ts";
|
|
12
|
+
import { resolveEffectiveSettings, topLeftSegments } from "../settings.ts";
|
|
13
|
+
import { easeFade, theme } from "../theme.ts";
|
|
14
|
+
import type { SegmentContext, StatusLineSegmentId } from "../types.ts";
|
|
15
|
+
|
|
16
|
+
const stripAnsi = (text: string) => text.replace(/\x1b\[[0-9;]*m/g, "");
|
|
17
|
+
const noGap = (s: string) => s;
|
|
18
|
+
|
|
19
|
+
const EFFECTIVE = resolveEffectiveSettings({ transparent: true });
|
|
20
|
+
|
|
21
|
+
function ctxWith(workingStatus: string | undefined): SegmentContext {
|
|
22
|
+
return {
|
|
23
|
+
options: EFFECTIVE.segmentOptions,
|
|
24
|
+
model: undefined,
|
|
25
|
+
thinkingLevel: "off",
|
|
26
|
+
cwd: "/tmp/pi-topping-statusline-tests",
|
|
27
|
+
sessionName: undefined,
|
|
28
|
+
contextPercent: null,
|
|
29
|
+
contextTokens: 0,
|
|
30
|
+
contextWindow: 0,
|
|
31
|
+
git: { branch: null, status: null, pr: null },
|
|
32
|
+
worktree: null,
|
|
33
|
+
scrollHint: undefined,
|
|
34
|
+
piStats: undefined,
|
|
35
|
+
tokenRate: undefined,
|
|
36
|
+
feedData: undefined,
|
|
37
|
+
workingStatus,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("working segment renders the host text verbatim", () => {
|
|
42
|
+
const status = "\x1b[36m⠙ Working\x1b[39m";
|
|
43
|
+
assert.deepEqual(SEGMENTS.working.render(ctxWith(status)), { content: status, visible: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("working segment is invisible when idle", () => {
|
|
47
|
+
assert.equal(SEGMENTS.working.render(ctxWith(undefined)).visible, false);
|
|
48
|
+
assert.equal(SEGMENTS.working.render(ctxWith("")).visible, false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("topLeftSegments keeps pi first and swaps the rest while streaming", () => {
|
|
52
|
+
assert.deepEqual(topLeftSegments(EFFECTIVE, true), ["pi", "working"]);
|
|
53
|
+
assert.deepEqual(topLeftSegments(EFFECTIVE, false), EFFECTIVE.leftSegments);
|
|
54
|
+
assert.ok(EFFECTIVE.leftSegments.length > 2, "default left group has more than pi + one segment");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("topLeftSegments yields only working when the pi symbol is off", () => {
|
|
58
|
+
const noPi = resolveEffectiveSettings({ segments: { pi: false } });
|
|
59
|
+
assert.deepEqual(topLeftSegments(noPi, true), ["working"]);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("embedWorkingStatus resolves off by default and on when set", () => {
|
|
63
|
+
assert.equal(resolveEffectiveSettings({}).embedWorkingStatus, false);
|
|
64
|
+
assert.equal(resolveEffectiveSettings({ embedWorkingStatus: true }).embedWorkingStatus, true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("top bar renders pi symbol, separator, then the status", () => {
|
|
68
|
+
const bar = buildStatusLine(80, ctxWith("⠙ Mulling 28 tps"), EFFECTIVE, noGap, {
|
|
69
|
+
left: ["pi", "working"],
|
|
70
|
+
right: [],
|
|
71
|
+
});
|
|
72
|
+
const plain = stripAnsi(bar);
|
|
73
|
+
const piAt = plain.indexOf(theme.icon.pi);
|
|
74
|
+
const statusAt = plain.indexOf("⠙ Mulling 28 tps");
|
|
75
|
+
assert.ok(piAt >= 0, "pi symbol present");
|
|
76
|
+
assert.ok(statusAt > piAt, "status follows the pi symbol");
|
|
77
|
+
assert.equal(visibleWidth(bar), 80);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("narrow widths truncate the status without an ellipsis and keep the pi symbol", () => {
|
|
81
|
+
const status = "⠙ Mulling ⣾⣿⣿⣿⣿⣿⣾⣾ 28 tps · 11s · ↓ 316 tokens";
|
|
82
|
+
const bar = buildStatusLine(20, ctxWith(status), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
|
|
83
|
+
const plain = stripAnsi(bar);
|
|
84
|
+
assert.ok(plain.includes(theme.icon.pi), "pi symbol survives");
|
|
85
|
+
assert.ok(plain.includes("⠙ Mull"), "status head is kept");
|
|
86
|
+
assert.ok(!plain.includes("…"), "no ellipsis");
|
|
87
|
+
assert.ok(!plain.includes("tokens"), "status tail is cut");
|
|
88
|
+
assert.equal(visibleWidth(bar), 20);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("a long ANSI-styled status never exceeds the bar width", () => {
|
|
92
|
+
const status = `\x1b[38;2;10;20;30m⠙ ${"Working ".repeat(30)}\x1b[39m`;
|
|
93
|
+
for (const width of [12, 16, 24, 40, 60]) {
|
|
94
|
+
const bar = buildStatusLine(width, ctxWith(status), EFFECTIVE, noGap, {
|
|
95
|
+
left: ["pi", "working"],
|
|
96
|
+
right: [],
|
|
97
|
+
});
|
|
98
|
+
assert.equal(visibleWidth(bar), width, `width ${width}`);
|
|
99
|
+
assert.ok(stripAnsi(bar).includes(theme.icon.pi), `pi symbol at width ${width}`);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("the status drops entirely rather than the pi symbol when there is no room", () => {
|
|
104
|
+
const bar = buildStatusLine(4, ctxWith("⠙ Working"), EFFECTIVE, noGap, { left: ["pi", "working"], right: [] });
|
|
105
|
+
const plain = stripAnsi(bar);
|
|
106
|
+
assert.ok(plain.includes(theme.icon.pi));
|
|
107
|
+
assert.ok(!plain.includes("⠙"));
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("fadeAnsi leaves text untouched at full opacity and sinks colors into the bar at zero", () => {
|
|
111
|
+
const styled = "\x1b[38;2;254;188;56mA\x1b[39m \x1b[48;5;70mB\x1b[49m";
|
|
112
|
+
assert.equal(theme.fadeAnsi(styled, 1), styled);
|
|
113
|
+
const sunk = theme.fadeAnsi(styled, 0);
|
|
114
|
+
assert.equal(
|
|
115
|
+
sunk,
|
|
116
|
+
`${theme.getFgAnsi("statusLineBg")}A${theme.getFgAnsi("statusLineBg")} ${theme.getBgAnsi()}B\x1b[49m`,
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("easeFade is monotonic and pinned at both ends", () => {
|
|
121
|
+
assert.equal(easeFade(0), 0);
|
|
122
|
+
assert.equal(easeFade(1), 1);
|
|
123
|
+
assert.ok(easeFade(0.25) < easeFade(0.5) && easeFade(0.5) < easeFade(0.75));
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("leftFade recolors the tail but not the pi symbol or the bar width", () => {
|
|
127
|
+
const status = "\x1b[38;2;10;200;30m⠙ Working\x1b[39m";
|
|
128
|
+
const groups: { left: StatusLineSegmentId[]; right: StatusLineSegmentId[] } = { left: ["pi", "working"], right: [] };
|
|
129
|
+
const origin = { col: 0, row: 0 };
|
|
130
|
+
const build = (fade?: number) =>
|
|
131
|
+
buildStatusLine(60, ctxWith(status), EFFECTIVE, noGap, groups, origin, { leftFade: fade });
|
|
132
|
+
const solid = build();
|
|
133
|
+
const faded = build(0.2);
|
|
134
|
+
assert.equal(visibleWidth(faded), 60);
|
|
135
|
+
assert.equal(stripAnsi(faded), stripAnsi(solid));
|
|
136
|
+
assert.ok(!faded.includes("10;200;30"), "status color blended");
|
|
137
|
+
const piPrefix = (bar: string) => bar.slice(0, bar.indexOf(theme.icon.pi));
|
|
138
|
+
assert.equal(piPrefix(faded), piPrefix(solid), "pi symbol styling untouched");
|
|
139
|
+
assert.equal(build(1), solid);
|
|
140
|
+
});
|
package/src/theme.ts
CHANGED
|
@@ -178,7 +178,6 @@ type ColorValue = string | number;
|
|
|
178
178
|
|
|
179
179
|
export type StatusColor =
|
|
180
180
|
| "accent"
|
|
181
|
-
| "success"
|
|
182
181
|
| "error"
|
|
183
182
|
| "warning"
|
|
184
183
|
| "muted"
|
|
@@ -193,11 +192,13 @@ export type StatusColor =
|
|
|
193
192
|
| "statusLineContextElevated"
|
|
194
193
|
| "statusLineStaged"
|
|
195
194
|
| "statusLineDirty"
|
|
196
|
-
| "statusLineUntracked"
|
|
195
|
+
| "statusLineUntracked"
|
|
196
|
+
| "statusLineBg";
|
|
197
|
+
|
|
198
|
+
const STATUS_LINE_BG: ColorValue = "#121212";
|
|
197
199
|
|
|
198
200
|
const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
199
201
|
accent: "#febc38",
|
|
200
|
-
success: "#89d281",
|
|
201
202
|
error: "#fc3a4b",
|
|
202
203
|
warning: "#e4c00f",
|
|
203
204
|
muted: "#777d88",
|
|
@@ -213,10 +214,9 @@ const FG_COLORS: Record<StatusColor, ColorValue> = {
|
|
|
213
214
|
statusLineStaged: 70,
|
|
214
215
|
statusLineDirty: 178,
|
|
215
216
|
statusLineUntracked: 39,
|
|
217
|
+
statusLineBg: STATUS_LINE_BG,
|
|
216
218
|
};
|
|
217
219
|
|
|
218
|
-
const STATUS_LINE_BG: ColorValue = "#121212";
|
|
219
|
-
|
|
220
220
|
/** Dominant palette hexes the session accent must not collide with. */
|
|
221
221
|
export const MAJOR_COLOR_HEXES = [
|
|
222
222
|
"#febc38",
|
|
@@ -246,9 +246,21 @@ export function parseHex(hex: string): [number, number, number] | undefined {
|
|
|
246
246
|
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
/** Number of discrete, eased shades used for the token-rate
|
|
249
|
+
/** Number of discrete, eased shades used for the token-rate and feed fades (pi-topping). */
|
|
250
250
|
export const FADE_SHADE_COUNT = 5;
|
|
251
251
|
|
|
252
|
+
/** Stand-in for the terminal's default foreground when a fade must recolor it. */
|
|
253
|
+
const DEFAULT_FG_RGB: [number, number, number] = [204, 204, 204];
|
|
254
|
+
|
|
255
|
+
/** Every SGR color the layout or a segment can emit: truecolor, 256-color, or the default-fg reset. */
|
|
256
|
+
const SGR_COLOR_RE = /\x1b\[(38|48);2;(\d+);(\d+);(\d+)m|\x1b\[(38|48);5;(\d+)m|\x1b\[39m/g;
|
|
257
|
+
|
|
258
|
+
/** Raised-cosine ease shared by the discrete and continuous fades. */
|
|
259
|
+
export function easeFade(progress: number): number {
|
|
260
|
+
const clamped = Math.max(0, Math.min(1, progress));
|
|
261
|
+
return 0.5 * (1 - Math.cos(Math.PI * clamped));
|
|
262
|
+
}
|
|
263
|
+
|
|
252
264
|
/** Resolve an xterm-256 palette index to RGB (16–231 cube, 232–255 grayscale). */
|
|
253
265
|
function xterm256ToRgb(n: number): [number, number, number] {
|
|
254
266
|
if (n >= 232) {
|
|
@@ -275,11 +287,14 @@ function rgbTo256(r: number, g: number, b: number): number {
|
|
|
275
287
|
return 16 + 36 * scale(r) + 6 * scale(g) + scale(b);
|
|
276
288
|
}
|
|
277
289
|
|
|
290
|
+
function rgbToFgAnsi(r: number, g: number, b: number, mode: ColorMode): string {
|
|
291
|
+
return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
292
|
+
}
|
|
293
|
+
|
|
278
294
|
export function hexToFgAnsi(hex: string, mode: ColorMode = detectColorMode()): string {
|
|
279
295
|
const rgb = parseHex(hex);
|
|
280
296
|
if (!rgb) return "";
|
|
281
|
-
|
|
282
|
-
return mode === "truecolor" ? `\x1b[38;2;${r};${g};${b}m` : `\x1b[38;5;${rgbTo256(r, g, b)}m`;
|
|
297
|
+
return rgbToFgAnsi(...rgb, mode);
|
|
283
298
|
}
|
|
284
299
|
|
|
285
300
|
function fgAnsi(color: ColorValue, mode: ColorMode): string {
|
|
@@ -355,16 +370,44 @@ class StatusTheme {
|
|
|
355
370
|
fadeFg(from: StatusColor, to: StatusColor, shade: number, text: string): string {
|
|
356
371
|
const clamped = Math.max(0, Math.min(FADE_SHADE_COUNT - 1, Math.floor(shade)));
|
|
357
372
|
const progress = (clamped + 1) / FADE_SHADE_COUNT;
|
|
358
|
-
const eased =
|
|
373
|
+
const eased = easeFade(progress);
|
|
359
374
|
const a = colorValueToRgb(FG_COLORS[from]);
|
|
360
375
|
const b = colorValueToRgb(FG_COLORS[to]);
|
|
361
376
|
const r = Math.round(a[0] * (1 - eased) + b[0] * eased);
|
|
362
377
|
const g = Math.round(a[1] * (1 - eased) + b[1] * eased);
|
|
363
378
|
const bl = Math.round(a[2] * (1 - eased) + b[2] * eased);
|
|
364
|
-
const ansi =
|
|
365
|
-
this.#mode === "truecolor" ? `\x1b[38;2;${r};${g};${bl}m` : `\x1b[38;5;${rgbTo256(r, g, bl)}m`;
|
|
379
|
+
const ansi = rgbToFgAnsi(r, g, bl, this.#mode);
|
|
366
380
|
return `${ansi}${text}\x1b[39m`;
|
|
367
381
|
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Blend every color already present in `text` toward the bar background
|
|
385
|
+
* by `opacity` (1 = untouched, 0 = fully sunk into the background), so a
|
|
386
|
+
* fully styled group can fade as a whole without re-rendering its segments.
|
|
387
|
+
* Default-foreground resets are recolored from a neutral stand-in so plain
|
|
388
|
+
* labels fade alongside their colored neighbors; background resets (49)
|
|
389
|
+
* stay put so transparent bars keep the terminal's own background.
|
|
390
|
+
*/
|
|
391
|
+
fadeAnsi(text: string, opacity: number): string {
|
|
392
|
+
const alpha = Math.max(0, Math.min(1, opacity));
|
|
393
|
+
if (alpha >= 1) return text;
|
|
394
|
+
const target = colorValueToRgb(STATUS_LINE_BG);
|
|
395
|
+
const mode = this.#mode;
|
|
396
|
+
return text.replace(SGR_COLOR_RE, (_m, tcPlane, r, g, b, ixPlane, index) => {
|
|
397
|
+
let plane = "38";
|
|
398
|
+
let rgb: [number, number, number] = DEFAULT_FG_RGB;
|
|
399
|
+
if (tcPlane) {
|
|
400
|
+
plane = tcPlane;
|
|
401
|
+
rgb = [Number(r), Number(g), Number(b)];
|
|
402
|
+
} else if (ixPlane) {
|
|
403
|
+
plane = ixPlane;
|
|
404
|
+
rgb = xterm256ToRgb(Number(index));
|
|
405
|
+
}
|
|
406
|
+
const mix = (i: number) => Math.round(rgb[i] * alpha + target[i] * (1 - alpha));
|
|
407
|
+
const fg = rgbToFgAnsi(mix(0), mix(1), mix(2), mode);
|
|
408
|
+
return plane === "38" ? fg : fg.replace("\x1b[38;", "\x1b[48;");
|
|
409
|
+
});
|
|
410
|
+
}
|
|
368
411
|
}
|
|
369
412
|
|
|
370
413
|
export const theme = new StatusTheme();
|
package/src/types.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type StatusLineSegmentId =
|
|
|
18
18
|
| "token_rate"
|
|
19
19
|
| "feeds"
|
|
20
20
|
| "context_graph"
|
|
21
|
-
| "scroll_hint"
|
|
21
|
+
| "scroll_hint"
|
|
22
|
+
| "working";
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* A feed the statusline subscribes to: custom session entries published by
|
|
@@ -73,6 +74,8 @@ export interface StatusLineSettings {
|
|
|
73
74
|
rainbowBorder?: boolean;
|
|
74
75
|
/** Animate the rainbow border when it is visible. */
|
|
75
76
|
rainbowAnimation?: boolean;
|
|
77
|
+
/** Render pi's streaming working status in the top bar instead of its own row. */
|
|
78
|
+
embedWorkingStatus?: boolean;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/** Resolved per-segment render options; resolveEffectiveSettings always fills them. */
|
|
@@ -105,6 +108,7 @@ export interface EffectiveStatusLineSettings {
|
|
|
105
108
|
segmentOptions: StatusLineSegmentOptions;
|
|
106
109
|
rainbowBorder: boolean;
|
|
107
110
|
rainbowAnimation: boolean;
|
|
111
|
+
embedWorkingStatus: boolean;
|
|
108
112
|
includes: SegmentIncludes;
|
|
109
113
|
}
|
|
110
114
|
|
|
@@ -133,6 +137,11 @@ export interface SegmentContext {
|
|
|
133
137
|
scrollHint: string | undefined;
|
|
134
138
|
/** Pi's own footer stats, when this frame needs them. */
|
|
135
139
|
piStats: string | undefined;
|
|
140
|
+
/**
|
|
141
|
+
* Pi's embedded working indicator, pre-rendered and pre-colored by the host
|
|
142
|
+
* for this frame. Absent while no response is streaming.
|
|
143
|
+
*/
|
|
144
|
+
workingStatus?: string;
|
|
136
145
|
/** Live tok/s display state, when this frame needs it. */
|
|
137
146
|
tokenRate: TokenRateDisplay | undefined;
|
|
138
147
|
/**
|
|
@@ -142,6 +151,19 @@ export interface SegmentContext {
|
|
|
142
151
|
* entries, and those must not be shown as current.
|
|
143
152
|
*/
|
|
144
153
|
feedData: Record<string, unknown> | undefined;
|
|
154
|
+
/**
|
|
155
|
+
* Lifecycle state per configured feed field. Missing state is treated as
|
|
156
|
+
* active so previews and manually constructed contexts retain full color.
|
|
157
|
+
*/
|
|
158
|
+
feedDisplayState?: Record<string, FeedDisplayState>;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export type FeedPhase = "active" | "fading" | "hidden";
|
|
162
|
+
|
|
163
|
+
export interface FeedDisplayState {
|
|
164
|
+
phase: FeedPhase;
|
|
165
|
+
/** Discrete fade step (0..FADE_SHADE_COUNT-1); meaningful while fading. */
|
|
166
|
+
fadeShade: number;
|
|
145
167
|
}
|
|
146
168
|
|
|
147
169
|
export type TokenRatePhase = "active" | "fading" | "idle";
|
package/src/utils.ts
CHANGED
|
@@ -65,10 +65,7 @@ export function clampPathLength(pwd: string, maxLen: number): string {
|
|
|
65
65
|
|
|
66
66
|
/** Strip ANSI/VT escapes, map control chars to spaces, collapse whitespace. */
|
|
67
67
|
export function sanitizeStatusText(text: string): string {
|
|
68
|
-
return
|
|
69
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
70
|
-
.replace(/ +/g, " ")
|
|
71
|
-
.trim();
|
|
68
|
+
return sanitizeLabel(text).trim();
|
|
72
69
|
}
|
|
73
70
|
|
|
74
71
|
/**
|
|
@@ -77,7 +74,7 @@ export function sanitizeStatusText(text: string): string {
|
|
|
77
74
|
*/
|
|
78
75
|
export function sanitizeLabel(text: string): string {
|
|
79
76
|
return stripVTControlCharacters(text)
|
|
80
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
77
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069\ufeff]/g, " ")
|
|
81
78
|
.replace(/ +/g, " ");
|
|
82
79
|
}
|
|
83
80
|
|
|
@@ -85,6 +82,11 @@ export function withIcon(icon: string, text: string): string {
|
|
|
85
82
|
return icon ? `${icon} ${text}` : text;
|
|
86
83
|
}
|
|
87
84
|
|
|
85
|
+
/** Unambiguous identity for one configured field of a feed publisher. */
|
|
86
|
+
export function feedKey(customType: string, field: string): string {
|
|
87
|
+
return `${customType}\u0000${field}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
88
90
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
89
91
|
// Session accent (djb2 hue hash, dark warm band, collision avoidance)
|
|
90
92
|
// ═══════════════════════════════════════════════════════════════════════════
|