pi-powerline-footer 0.2.6 → 0.2.8
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/CHANGELOG.md +11 -0
- package/index.ts +90 -39
- package/package.json +1 -1
- package/welcome.ts +250 -231
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.8] - 2026-01-16
|
|
4
|
+
|
|
5
|
+
### Changed
|
|
6
|
+
- `quietStartup: true` → shows welcome as header (dismisses on first input)
|
|
7
|
+
- `quietStartup: false` or not set → shows welcome as centered overlay (dismisses on key/timeout)
|
|
8
|
+
- Both modes use same two-column layout: logo, model info, tips, loaded counts, recent sessions
|
|
9
|
+
- Refactored welcome.ts to share rendering logic between header and overlay
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- `/powerline` toggle off now clears all custom UI (editor, footer, header)
|
|
13
|
+
|
|
3
14
|
## [0.2.6] - 2026-01-16
|
|
4
15
|
|
|
5
16
|
### Fixed
|
package/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI, ReadonlyFooterDataProvider } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
3
3
|
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
4
|
-
import { readFileSync } from "node:fs";
|
|
4
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
5
6
|
|
|
6
7
|
import type { SegmentContext, StatusLinePreset } from "./types.js";
|
|
7
8
|
import { getPreset, PRESETS } from "./presets.js";
|
|
@@ -9,7 +10,7 @@ import { getSeparator } from "./separators.js";
|
|
|
9
10
|
import { renderSegment } from "./segments.js";
|
|
10
11
|
import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
|
|
11
12
|
import { ansi, getFgAnsiCode } from "./colors.js";
|
|
12
|
-
import { WelcomeComponent, discoverLoadedCounts, getRecentSessions } from "./welcome.js";
|
|
13
|
+
import { WelcomeComponent, WelcomeHeader, discoverLoadedCounts, getRecentSessions } from "./welcome.js";
|
|
13
14
|
|
|
14
15
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
16
|
// Configuration
|
|
@@ -23,6 +24,21 @@ let config: PowerlineConfig = {
|
|
|
23
24
|
preset: "default",
|
|
24
25
|
};
|
|
25
26
|
|
|
27
|
+
// Check if quietStartup is enabled in settings
|
|
28
|
+
function isQuietStartup(): boolean {
|
|
29
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
30
|
+
const settingsPath = join(homeDir, ".pi", "agent", "settings.json");
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(settingsPath)) {
|
|
34
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
35
|
+
return settings.quietStartup === true;
|
|
36
|
+
}
|
|
37
|
+
} catch {}
|
|
38
|
+
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
43
|
// Status Line Builder (for top border)
|
|
28
44
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -71,6 +87,8 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
71
87
|
let getThinkingLevelFn: (() => string) | null = null;
|
|
72
88
|
let isStreaming = false;
|
|
73
89
|
let tuiRef: any = null; // Store TUI reference for forcing re-renders
|
|
90
|
+
let dismissWelcomeOverlay: (() => void) | null = null; // Callback to dismiss welcome overlay
|
|
91
|
+
let welcomeHeaderActive = false; // Track if welcome header should be cleared on first input
|
|
74
92
|
|
|
75
93
|
// Track session start
|
|
76
94
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -84,7 +102,12 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
84
102
|
|
|
85
103
|
if (enabled && ctx.hasUI) {
|
|
86
104
|
setupCustomEditor(ctx);
|
|
87
|
-
|
|
105
|
+
// quietStartup: true → compact header, otherwise → full overlay
|
|
106
|
+
if (isQuietStartup()) {
|
|
107
|
+
setupWelcomeHeader(ctx);
|
|
108
|
+
} else {
|
|
109
|
+
setupWelcomeOverlay(ctx);
|
|
110
|
+
}
|
|
88
111
|
}
|
|
89
112
|
});
|
|
90
113
|
|
|
@@ -139,6 +162,18 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
139
162
|
isStreaming = false;
|
|
140
163
|
});
|
|
141
164
|
|
|
165
|
+
// Dismiss welcome overlay/header on first user message
|
|
166
|
+
pi.on("user_message", async (_event, ctx) => {
|
|
167
|
+
if (dismissWelcomeOverlay) {
|
|
168
|
+
dismissWelcomeOverlay();
|
|
169
|
+
dismissWelcomeOverlay = null;
|
|
170
|
+
}
|
|
171
|
+
if (welcomeHeaderActive) {
|
|
172
|
+
welcomeHeaderActive = false;
|
|
173
|
+
ctx.ui.setHeader(undefined);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
142
177
|
// Command to toggle/configure
|
|
143
178
|
pi.registerCommand("powerline", {
|
|
144
179
|
description: "Configure powerline status (toggle, preset)",
|
|
@@ -153,9 +188,10 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
153
188
|
setupCustomEditor(ctx);
|
|
154
189
|
ctx.ui.notify("Powerline enabled", "info");
|
|
155
190
|
} else {
|
|
156
|
-
//
|
|
191
|
+
// Clear all custom UI components
|
|
157
192
|
ctx.ui.setEditorComponent(undefined);
|
|
158
193
|
ctx.ui.setFooter(undefined);
|
|
194
|
+
ctx.ui.setHeader(undefined);
|
|
159
195
|
footerDataRef = null;
|
|
160
196
|
tuiRef = null;
|
|
161
197
|
ctx.ui.notify("Defaults restored", "info");
|
|
@@ -249,6 +285,22 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
249
285
|
// Create custom editor that overrides render for status in top border
|
|
250
286
|
const editor = new CustomEditor(tui, theme, keybindings);
|
|
251
287
|
|
|
288
|
+
// Override handleInput to dismiss welcome on first keypress
|
|
289
|
+
const originalHandleInput = editor.handleInput.bind(editor);
|
|
290
|
+
editor.handleInput = (data: string) => {
|
|
291
|
+
// Dismiss welcome overlay/header on first keypress
|
|
292
|
+
if (dismissWelcomeOverlay) {
|
|
293
|
+
const dismiss = dismissWelcomeOverlay;
|
|
294
|
+
dismissWelcomeOverlay = null;
|
|
295
|
+
setTimeout(dismiss, 0);
|
|
296
|
+
}
|
|
297
|
+
if (welcomeHeaderActive) {
|
|
298
|
+
welcomeHeaderActive = false;
|
|
299
|
+
ctx.ui.setHeader(undefined);
|
|
300
|
+
}
|
|
301
|
+
originalHandleInput(data);
|
|
302
|
+
};
|
|
303
|
+
|
|
252
304
|
// Store original render
|
|
253
305
|
const originalRender = editor.render.bind(editor);
|
|
254
306
|
|
|
@@ -299,10 +351,9 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
299
351
|
} else {
|
|
300
352
|
// Status too wide - truncate by removing segments from the end
|
|
301
353
|
// Build progressively shorter content until it fits
|
|
302
|
-
const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
|
|
303
354
|
let truncatedContent = "";
|
|
304
355
|
|
|
305
|
-
for (let numSegments =
|
|
356
|
+
for (let numSegments = presetDef.leftSegments.length - 1; numSegments >= 1; numSegments--) {
|
|
306
357
|
const limitedPreset = {
|
|
307
358
|
...presetDef,
|
|
308
359
|
leftSegments: presetDef.leftSegments.slice(0, numSegments),
|
|
@@ -368,8 +419,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
368
419
|
return {
|
|
369
420
|
dispose: unsub,
|
|
370
421
|
invalidate() {
|
|
371
|
-
//
|
|
372
|
-
tui.requestRender();
|
|
422
|
+
// No cache to clear - render is always fresh
|
|
373
423
|
},
|
|
374
424
|
render(width: number): string[] {
|
|
375
425
|
// Only show status in footer during streaming (editor hidden)
|
|
@@ -388,10 +438,9 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
388
438
|
return [statusContent + " ".repeat(width - statusWidth)];
|
|
389
439
|
} else {
|
|
390
440
|
// Truncate by removing segments (same logic as editor)
|
|
391
|
-
const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
|
|
392
441
|
let truncatedContent = "";
|
|
393
442
|
|
|
394
|
-
for (let numSegments =
|
|
443
|
+
for (let numSegments = presetDef.leftSegments.length - 1; numSegments >= 1; numSegments--) {
|
|
395
444
|
const limitedPreset = {
|
|
396
445
|
...presetDef,
|
|
397
446
|
leftSegments: presetDef.leftSegments.slice(0, numSegments),
|
|
@@ -413,30 +462,38 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
413
462
|
});
|
|
414
463
|
}
|
|
415
464
|
|
|
416
|
-
function
|
|
417
|
-
// Get version from package.json
|
|
418
|
-
let version = "0.0.0";
|
|
419
|
-
try {
|
|
420
|
-
const pkgPath = new URL("./package.json", import.meta.url).pathname;
|
|
421
|
-
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
422
|
-
version = pkg.version || version;
|
|
423
|
-
} catch {}
|
|
424
|
-
|
|
425
|
-
// Get model info
|
|
465
|
+
function setupWelcomeHeader(ctx: any) {
|
|
426
466
|
const modelName = ctx.model?.name || ctx.model?.id || "No model";
|
|
427
467
|
const providerName = ctx.model?.provider || "Unknown";
|
|
468
|
+
const loadedCounts = discoverLoadedCounts();
|
|
469
|
+
const recentSessions = getRecentSessions(3);
|
|
470
|
+
|
|
471
|
+
const header = new WelcomeHeader(modelName, providerName, recentSessions, loadedCounts);
|
|
472
|
+
welcomeHeaderActive = true; // Will be cleared on first user input
|
|
428
473
|
|
|
429
|
-
|
|
474
|
+
ctx.ui.setHeader((_tui: any, _theme: any) => {
|
|
475
|
+
return {
|
|
476
|
+
render(width: number): string[] {
|
|
477
|
+
return header.render(width);
|
|
478
|
+
},
|
|
479
|
+
invalidate() {
|
|
480
|
+
header.invalidate();
|
|
481
|
+
},
|
|
482
|
+
};
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function setupWelcomeOverlay(ctx: any) {
|
|
487
|
+
const modelName = ctx.model?.name || ctx.model?.id || "No model";
|
|
488
|
+
const providerName = ctx.model?.provider || "Unknown";
|
|
430
489
|
const loadedCounts = discoverLoadedCounts();
|
|
431
490
|
const recentSessions = getRecentSessions(3);
|
|
432
491
|
|
|
433
|
-
// Small delay to let pi-mono finish initialization
|
|
492
|
+
// Small delay to let pi-mono finish initialization
|
|
434
493
|
setTimeout(() => {
|
|
435
|
-
// Show welcome as an overlay that dismisses on any key or after timeout
|
|
436
494
|
ctx.ui.custom(
|
|
437
495
|
(tui: any, _theme: any, _keybindings: any, done: (result: void) => void) => {
|
|
438
496
|
const welcome = new WelcomeComponent(
|
|
439
|
-
version,
|
|
440
497
|
modelName,
|
|
441
498
|
providerName,
|
|
442
499
|
recentSessions,
|
|
@@ -450,30 +507,26 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
450
507
|
if (dismissed) return;
|
|
451
508
|
dismissed = true;
|
|
452
509
|
clearInterval(interval);
|
|
510
|
+
dismissWelcomeOverlay = null;
|
|
453
511
|
done();
|
|
454
512
|
};
|
|
455
513
|
|
|
456
|
-
//
|
|
514
|
+
// Store dismiss callback so user_message/keypress can trigger it
|
|
515
|
+
dismissWelcomeOverlay = dismiss;
|
|
516
|
+
|
|
457
517
|
const interval = setInterval(() => {
|
|
458
518
|
if (dismissed) return;
|
|
459
519
|
countdown--;
|
|
460
520
|
welcome.setCountdown(countdown);
|
|
461
521
|
tui.requestRender();
|
|
462
|
-
|
|
463
|
-
if (countdown <= 0) {
|
|
464
|
-
dismiss();
|
|
465
|
-
}
|
|
522
|
+
if (countdown <= 0) dismiss();
|
|
466
523
|
}, 1000);
|
|
467
524
|
|
|
468
|
-
// Create a focusable wrapper component
|
|
469
|
-
// Must have 'focused' property for TUI to recognize it as focusable
|
|
470
525
|
return {
|
|
471
|
-
focused: false,
|
|
526
|
+
focused: false,
|
|
472
527
|
invalidate: () => welcome.invalidate(),
|
|
473
528
|
render: (width: number) => welcome.render(width),
|
|
474
|
-
handleInput: (_data: string) =>
|
|
475
|
-
dismiss();
|
|
476
|
-
},
|
|
529
|
+
handleInput: (_data: string) => dismiss(),
|
|
477
530
|
dispose: () => {
|
|
478
531
|
dismissed = true;
|
|
479
532
|
clearInterval(interval);
|
|
@@ -487,9 +540,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
487
540
|
horizontalAlign: "center",
|
|
488
541
|
}),
|
|
489
542
|
},
|
|
490
|
-
).catch(() => {
|
|
491
|
-
|
|
492
|
-
});
|
|
493
|
-
}, 100); // Small delay to let init complete
|
|
543
|
+
).catch(() => {});
|
|
544
|
+
}, 100);
|
|
494
545
|
}
|
|
495
546
|
}
|
package/package.json
CHANGED
package/welcome.ts
CHANGED
|
@@ -16,31 +16,232 @@ export interface LoadedCounts {
|
|
|
16
16
|
promptTemplates: number;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
// Shared rendering utilities
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
|
|
23
|
+
const PI_LOGO = [
|
|
24
|
+
"▀████████████▀",
|
|
25
|
+
" ╘███ ███ ",
|
|
26
|
+
" ███ ███ ",
|
|
27
|
+
" ███ ███ ",
|
|
28
|
+
" ▄███▄ ▄███▄ ",
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
const GRADIENT_COLORS = [
|
|
32
|
+
"\x1b[38;5;199m",
|
|
33
|
+
"\x1b[38;5;171m",
|
|
34
|
+
"\x1b[38;5;135m",
|
|
35
|
+
"\x1b[38;5;99m",
|
|
36
|
+
"\x1b[38;5;75m",
|
|
37
|
+
"\x1b[38;5;51m",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function bold(text: string): string {
|
|
41
|
+
return `\x1b[1m${text}\x1b[22m`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function dim(text: string): string {
|
|
45
|
+
return getFgAnsiCode("sep") + text + ansi.reset;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkmark(): string {
|
|
49
|
+
return fgOnly("gitClean", "✓");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function gradientLine(line: string): string {
|
|
53
|
+
const reset = ansi.reset;
|
|
54
|
+
let result = "";
|
|
55
|
+
let colorIdx = 0;
|
|
56
|
+
const step = Math.max(1, Math.floor(line.length / GRADIENT_COLORS.length));
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < line.length; i++) {
|
|
59
|
+
if (i > 0 && i % step === 0 && colorIdx < GRADIENT_COLORS.length - 1) colorIdx++;
|
|
60
|
+
const char = line[i];
|
|
61
|
+
if (char !== " ") {
|
|
62
|
+
result += GRADIENT_COLORS[colorIdx] + char + reset;
|
|
63
|
+
} else {
|
|
64
|
+
result += char;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function centerText(text: string, width: number): string {
|
|
71
|
+
const visLen = visibleWidth(text);
|
|
72
|
+
if (visLen > width) return truncateToWidth(text, width);
|
|
73
|
+
if (visLen === width) return text;
|
|
74
|
+
const leftPad = Math.floor((width - visLen) / 2);
|
|
75
|
+
const rightPad = width - visLen - leftPad;
|
|
76
|
+
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fitToWidth(str: string, width: number): string {
|
|
80
|
+
const visLen = visibleWidth(str);
|
|
81
|
+
if (visLen > width) return truncateToWidth(str, width);
|
|
82
|
+
return str + " ".repeat(width - visLen);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function truncateToWidth(str: string, width: number): string {
|
|
86
|
+
const ellipsis = "…";
|
|
87
|
+
const maxWidth = Math.max(0, width - 1);
|
|
88
|
+
let truncated = "";
|
|
89
|
+
let currentWidth = 0;
|
|
90
|
+
let inEscape = false;
|
|
91
|
+
|
|
92
|
+
for (const char of str) {
|
|
93
|
+
if (char === "\x1b") inEscape = true;
|
|
94
|
+
if (inEscape) {
|
|
95
|
+
truncated += char;
|
|
96
|
+
if (char === "m") inEscape = false;
|
|
97
|
+
} else if (currentWidth < maxWidth) {
|
|
98
|
+
truncated += char;
|
|
99
|
+
currentWidth++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (visibleWidth(str) > width) return truncated + ellipsis;
|
|
104
|
+
return truncated;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface WelcomeData {
|
|
108
|
+
modelName: string;
|
|
109
|
+
providerName: string;
|
|
110
|
+
recentSessions: RecentSession[];
|
|
111
|
+
loadedCounts: LoadedCounts;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildLeftColumn(data: WelcomeData, colWidth: number): string[] {
|
|
115
|
+
const logoColored = PI_LOGO.map((line) => gradientLine(line));
|
|
116
|
+
|
|
117
|
+
return [
|
|
118
|
+
"",
|
|
119
|
+
centerText(bold("Welcome back!"), colWidth),
|
|
120
|
+
"",
|
|
121
|
+
...logoColored.map((l) => centerText(l, colWidth)),
|
|
122
|
+
"",
|
|
123
|
+
centerText(fgOnly("model", data.modelName), colWidth),
|
|
124
|
+
centerText(dim(data.providerName), colWidth),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function buildRightColumn(data: WelcomeData, colWidth: number): string[] {
|
|
129
|
+
const hChar = "─";
|
|
130
|
+
const separator = ` ${dim(hChar.repeat(colWidth - 2))}`;
|
|
131
|
+
|
|
132
|
+
// Session lines
|
|
133
|
+
const sessionLines: string[] = [];
|
|
134
|
+
if (data.recentSessions.length === 0) {
|
|
135
|
+
sessionLines.push(` ${dim("No recent sessions")}`);
|
|
136
|
+
} else {
|
|
137
|
+
for (const session of data.recentSessions.slice(0, 3)) {
|
|
138
|
+
sessionLines.push(
|
|
139
|
+
` ${dim("• ")}${fgOnly("path", session.name)}${dim(` (${session.timeAgo})`)}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Loaded counts lines
|
|
145
|
+
const countLines: string[] = [];
|
|
146
|
+
const { contextFiles, extensions, skills, promptTemplates } = data.loadedCounts;
|
|
147
|
+
|
|
148
|
+
if (contextFiles > 0 || extensions > 0 || skills > 0 || promptTemplates > 0) {
|
|
149
|
+
if (contextFiles > 0) {
|
|
150
|
+
countLines.push(` ${checkmark()} ${fgOnly("gitClean", `${contextFiles}`)} context file${contextFiles !== 1 ? "s" : ""}`);
|
|
151
|
+
}
|
|
152
|
+
if (extensions > 0) {
|
|
153
|
+
countLines.push(` ${checkmark()} ${fgOnly("gitClean", `${extensions}`)} extension${extensions !== 1 ? "s" : ""}`);
|
|
154
|
+
}
|
|
155
|
+
if (skills > 0) {
|
|
156
|
+
countLines.push(` ${checkmark()} ${fgOnly("gitClean", `${skills}`)} skill${skills !== 1 ? "s" : ""}`);
|
|
157
|
+
}
|
|
158
|
+
if (promptTemplates > 0) {
|
|
159
|
+
countLines.push(` ${checkmark()} ${fgOnly("gitClean", `${promptTemplates}`)} prompt template${promptTemplates !== 1 ? "s" : ""}`);
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
countLines.push(` ${dim("No extensions loaded")}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return [
|
|
166
|
+
` ${bold(fgOnly("accent", "Tips"))}`,
|
|
167
|
+
` ${dim("/")} for commands`,
|
|
168
|
+
` ${dim("!")} to run bash`,
|
|
169
|
+
` ${dim("Shift+Tab")} cycle thinking`,
|
|
170
|
+
separator,
|
|
171
|
+
` ${bold(fgOnly("accent", "Loaded"))}`,
|
|
172
|
+
...countLines,
|
|
173
|
+
separator,
|
|
174
|
+
` ${bold(fgOnly("accent", "Recent sessions"))}`,
|
|
175
|
+
...sessionLines,
|
|
176
|
+
"",
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderWelcomeBox(
|
|
181
|
+
data: WelcomeData,
|
|
182
|
+
termWidth: number,
|
|
183
|
+
bottomLine: string,
|
|
184
|
+
): string[] {
|
|
185
|
+
const minWidth = 76;
|
|
186
|
+
const maxWidth = 96;
|
|
187
|
+
const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
|
|
188
|
+
const leftCol = 26;
|
|
189
|
+
const rightCol = boxWidth - leftCol - 3;
|
|
190
|
+
|
|
191
|
+
const hChar = "─";
|
|
192
|
+
const v = dim("│");
|
|
193
|
+
const tl = dim("╭");
|
|
194
|
+
const tr = dim("╮");
|
|
195
|
+
const bl = dim("╰");
|
|
196
|
+
const br = dim("╯");
|
|
197
|
+
|
|
198
|
+
const leftLines = buildLeftColumn(data, leftCol);
|
|
199
|
+
const rightLines = buildRightColumn(data, rightCol);
|
|
200
|
+
|
|
201
|
+
const lines: string[] = [];
|
|
202
|
+
|
|
203
|
+
// Top border with title
|
|
204
|
+
const title = " pi agent ";
|
|
205
|
+
const titlePrefix = dim(hChar.repeat(3));
|
|
206
|
+
const titleStyled = titlePrefix + fgOnly("model", title);
|
|
207
|
+
const titleVisLen = 3 + visibleWidth(title);
|
|
208
|
+
const afterTitle = boxWidth - 2 - titleVisLen;
|
|
209
|
+
const afterTitleText = afterTitle > 0 ? dim(hChar.repeat(afterTitle)) : "";
|
|
210
|
+
lines.push(tl + titleStyled + afterTitleText + tr);
|
|
211
|
+
|
|
212
|
+
// Content rows
|
|
213
|
+
const maxRows = Math.max(leftLines.length, rightLines.length);
|
|
214
|
+
for (let i = 0; i < maxRows; i++) {
|
|
215
|
+
const left = fitToWidth(leftLines[i] ?? "", leftCol);
|
|
216
|
+
const right = fitToWidth(rightLines[i] ?? "", rightCol);
|
|
217
|
+
lines.push(v + left + v + right + v);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Bottom border
|
|
221
|
+
lines.push(bl + bottomLine + br);
|
|
222
|
+
|
|
223
|
+
return lines;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
227
|
+
// Welcome Components
|
|
228
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
229
|
+
|
|
19
230
|
/**
|
|
20
231
|
* Welcome overlay component for pi agent.
|
|
21
232
|
* Displays a branded splash screen with logo, tips, and loaded counts.
|
|
22
|
-
* Includes a countdown timer for auto-dismiss.
|
|
23
233
|
*/
|
|
24
234
|
export class WelcomeComponent implements Component {
|
|
25
|
-
private
|
|
26
|
-
private modelName: string;
|
|
27
|
-
private providerName: string;
|
|
28
|
-
private recentSessions: RecentSession[];
|
|
29
|
-
private loadedCounts: LoadedCounts;
|
|
235
|
+
private data: WelcomeData;
|
|
30
236
|
private countdown: number = 30;
|
|
31
237
|
|
|
32
238
|
constructor(
|
|
33
|
-
version: string,
|
|
34
239
|
modelName: string,
|
|
35
240
|
providerName: string,
|
|
36
241
|
recentSessions: RecentSession[] = [],
|
|
37
242
|
loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
|
|
38
243
|
) {
|
|
39
|
-
this.
|
|
40
|
-
this.modelName = modelName;
|
|
41
|
-
this.providerName = providerName;
|
|
42
|
-
this.recentSessions = recentSessions;
|
|
43
|
-
this.loadedCounts = loadedCounts;
|
|
244
|
+
this.data = { modelName, providerName, recentSessions, loadedCounts };
|
|
44
245
|
}
|
|
45
246
|
|
|
46
247
|
setCountdown(seconds: number): void {
|
|
@@ -50,227 +251,63 @@ export class WelcomeComponent implements Component {
|
|
|
50
251
|
invalidate(): void {}
|
|
51
252
|
|
|
52
253
|
render(termWidth: number): string[] {
|
|
53
|
-
// Box dimensions - responsive with min/max
|
|
54
254
|
const minWidth = 76;
|
|
55
255
|
const maxWidth = 96;
|
|
56
256
|
const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
|
|
57
|
-
const leftCol = 26;
|
|
58
|
-
const rightCol = boxWidth - leftCol - 3; // 3 = │ + │ + │
|
|
59
|
-
|
|
60
|
-
// Block-based PI logo (gradient: magenta → cyan)
|
|
61
|
-
const piLogo = [
|
|
62
|
-
"▀████████████▀",
|
|
63
|
-
" ╘███ ███ ",
|
|
64
|
-
" ███ ███ ",
|
|
65
|
-
" ███ ███ ",
|
|
66
|
-
" ▄███▄ ▄███▄ ",
|
|
67
|
-
];
|
|
68
|
-
|
|
69
|
-
// Apply gradient to logo
|
|
70
|
-
const logoColored = piLogo.map((line) => this.gradientLine(line));
|
|
71
|
-
|
|
72
|
-
// Left column - centered content
|
|
73
|
-
const leftLines = [
|
|
74
|
-
"",
|
|
75
|
-
this.centerText(this.bold("Welcome back!"), leftCol),
|
|
76
|
-
"",
|
|
77
|
-
...logoColored.map((l) => this.centerText(l, leftCol)),
|
|
78
|
-
"",
|
|
79
|
-
this.centerText(fgOnly("model", this.modelName), leftCol),
|
|
80
|
-
this.centerText(this.dim(this.providerName), leftCol),
|
|
81
|
-
];
|
|
82
|
-
|
|
83
|
-
// Right column separator
|
|
84
|
-
const separatorWidth = rightCol - 2;
|
|
85
|
-
const hChar = "─";
|
|
86
|
-
const separator = ` ${this.dim(hChar.repeat(separatorWidth))}`;
|
|
87
|
-
|
|
88
|
-
// Recent sessions content
|
|
89
|
-
const sessionLines: string[] = [];
|
|
90
|
-
if (this.recentSessions.length === 0) {
|
|
91
|
-
sessionLines.push(` ${this.dim("No recent sessions")}`);
|
|
92
|
-
} else {
|
|
93
|
-
for (const session of this.recentSessions.slice(0, 3)) {
|
|
94
|
-
sessionLines.push(
|
|
95
|
-
` ${this.dim("• ")}${fgOnly("path", session.name)}${this.dim(` (${session.timeAgo})`)}`,
|
|
96
|
-
);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Loaded counts content
|
|
101
|
-
const countLines: string[] = [];
|
|
102
|
-
const { contextFiles, extensions, skills, promptTemplates } = this.loadedCounts;
|
|
103
257
|
|
|
104
|
-
|
|
105
|
-
if (contextFiles > 0) {
|
|
106
|
-
countLines.push(` ${this.checkmark()} ${fgOnly("gitClean", `${contextFiles}`)} context file${contextFiles !== 1 ? "s" : ""}`);
|
|
107
|
-
}
|
|
108
|
-
if (extensions > 0) {
|
|
109
|
-
countLines.push(` ${this.checkmark()} ${fgOnly("gitClean", `${extensions}`)} extension${extensions !== 1 ? "s" : ""}`);
|
|
110
|
-
}
|
|
111
|
-
if (skills > 0) {
|
|
112
|
-
countLines.push(` ${this.checkmark()} ${fgOnly("gitClean", `${skills}`)} skill${skills !== 1 ? "s" : ""}`);
|
|
113
|
-
}
|
|
114
|
-
if (promptTemplates > 0) {
|
|
115
|
-
countLines.push(` ${this.checkmark()} ${fgOnly("gitClean", `${promptTemplates}`)} prompt template${promptTemplates !== 1 ? "s" : ""}`);
|
|
116
|
-
}
|
|
117
|
-
} else {
|
|
118
|
-
countLines.push(` ${this.dim("No extensions loaded")}`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Right column
|
|
122
|
-
const rightLines = [
|
|
123
|
-
` ${this.bold(fgOnly("accent", "Tips"))}`,
|
|
124
|
-
` ${this.dim("/")} for commands`,
|
|
125
|
-
` ${this.dim("!")} to run bash`,
|
|
126
|
-
` ${this.dim("Shift+Tab")} cycle thinking`,
|
|
127
|
-
separator,
|
|
128
|
-
` ${this.bold(fgOnly("accent", "Loaded"))}`,
|
|
129
|
-
...countLines,
|
|
130
|
-
separator,
|
|
131
|
-
` ${this.bold(fgOnly("accent", "Recent sessions"))}`,
|
|
132
|
-
...sessionLines,
|
|
133
|
-
"",
|
|
134
|
-
];
|
|
135
|
-
|
|
136
|
-
// Border characters (dim)
|
|
137
|
-
const v = this.dim("│");
|
|
138
|
-
const tl = this.dim("╭");
|
|
139
|
-
const tr = this.dim("╮");
|
|
140
|
-
const bl = this.dim("╰");
|
|
141
|
-
const br = this.dim("╯");
|
|
142
|
-
|
|
143
|
-
const lines: string[] = [];
|
|
144
|
-
|
|
145
|
-
// Top border with embedded title
|
|
146
|
-
const title = ` pi agent v${this.version} `;
|
|
147
|
-
const titlePrefix = this.dim(hChar.repeat(3));
|
|
148
|
-
const titleStyled = titlePrefix + fgOnly("model", title);
|
|
149
|
-
const titleVisLen = 3 + visibleWidth(title);
|
|
150
|
-
const afterTitle = boxWidth - 2 - titleVisLen;
|
|
151
|
-
const afterTitleText = afterTitle > 0 ? this.dim(hChar.repeat(afterTitle)) : "";
|
|
152
|
-
lines.push(tl + titleStyled + afterTitleText + tr);
|
|
153
|
-
|
|
154
|
-
// Content rows
|
|
155
|
-
const maxRows = Math.max(leftLines.length, rightLines.length);
|
|
156
|
-
for (let i = 0; i < maxRows; i++) {
|
|
157
|
-
const left = this.fitToWidth(leftLines[i] ?? "", leftCol);
|
|
158
|
-
const right = this.fitToWidth(rightLines[i] ?? "", rightCol);
|
|
159
|
-
lines.push(v + left + v + right + v);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// Bottom border with countdown
|
|
258
|
+
// Bottom line with countdown
|
|
163
259
|
const countdownText = ` Press any key to continue (${this.countdown}s) `;
|
|
164
|
-
const countdownStyled =
|
|
165
|
-
const bottomContentWidth = boxWidth - 2;
|
|
260
|
+
const countdownStyled = dim(countdownText);
|
|
261
|
+
const bottomContentWidth = boxWidth - 2;
|
|
166
262
|
const countdownVisLen = visibleWidth(countdownText);
|
|
167
263
|
const leftPad = Math.floor((bottomContentWidth - countdownVisLen) / 2);
|
|
168
264
|
const rightPad = bottomContentWidth - countdownVisLen - leftPad;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
bl +
|
|
172
|
-
this.dim(hChar.repeat(Math.max(0, leftPad))) +
|
|
265
|
+
const hChar = "─";
|
|
266
|
+
const bottomLine = dim(hChar.repeat(Math.max(0, leftPad))) +
|
|
173
267
|
countdownStyled +
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
return lines;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
private bold(text: string): string {
|
|
182
|
-
return `\x1b[1m${text}\x1b[22m`;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
private dim(text: string): string {
|
|
186
|
-
return getFgAnsiCode("sep") + text + ansi.reset;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
private checkmark(): string {
|
|
190
|
-
return fgOnly("gitClean", "✓");
|
|
268
|
+
dim(hChar.repeat(Math.max(0, rightPad)));
|
|
269
|
+
|
|
270
|
+
return renderWelcomeBox(this.data, termWidth, bottomLine);
|
|
191
271
|
}
|
|
272
|
+
}
|
|
192
273
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
if (visLen === width) {
|
|
200
|
-
return text; // Exact fit, no centering needed
|
|
201
|
-
}
|
|
202
|
-
const leftPad = Math.floor((width - visLen) / 2);
|
|
203
|
-
const rightPad = width - visLen - leftPad;
|
|
204
|
-
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
|
|
205
|
-
}
|
|
274
|
+
/**
|
|
275
|
+
* Welcome header - same layout as overlay but persistent (no countdown).
|
|
276
|
+
* Used when quietStartup: true.
|
|
277
|
+
*/
|
|
278
|
+
export class WelcomeHeader implements Component {
|
|
279
|
+
private data: WelcomeData;
|
|
206
280
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
"\x1b[38;5;75m", // cyan-blue
|
|
215
|
-
"\x1b[38;5;51m", // bright cyan
|
|
216
|
-
];
|
|
217
|
-
const reset = ansi.reset;
|
|
218
|
-
|
|
219
|
-
let result = "";
|
|
220
|
-
let colorIdx = 0;
|
|
221
|
-
const step = Math.max(1, Math.floor(line.length / colors.length));
|
|
222
|
-
|
|
223
|
-
for (let i = 0; i < line.length; i++) {
|
|
224
|
-
if (i > 0 && i % step === 0 && colorIdx < colors.length - 1) {
|
|
225
|
-
colorIdx++;
|
|
226
|
-
}
|
|
227
|
-
const char = line[i];
|
|
228
|
-
if (char !== " ") {
|
|
229
|
-
result += colors[colorIdx] + char + reset;
|
|
230
|
-
} else {
|
|
231
|
-
result += char;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
return result;
|
|
281
|
+
constructor(
|
|
282
|
+
modelName: string,
|
|
283
|
+
providerName: string,
|
|
284
|
+
recentSessions: RecentSession[] = [],
|
|
285
|
+
loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
|
|
286
|
+
) {
|
|
287
|
+
this.data = { modelName, providerName, recentSessions, loadedCounts };
|
|
235
288
|
}
|
|
236
289
|
|
|
237
|
-
|
|
238
|
-
private fitToWidth(str: string, width: number): string {
|
|
239
|
-
const visLen = visibleWidth(str);
|
|
240
|
-
if (visLen > width) {
|
|
241
|
-
return this.truncateToWidth(str, width);
|
|
242
|
-
}
|
|
243
|
-
return str + " ".repeat(width - visLen);
|
|
244
|
-
}
|
|
290
|
+
invalidate(): void {}
|
|
245
291
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
let currentWidth = 0;
|
|
252
|
-
let inEscape = false;
|
|
292
|
+
render(termWidth: number): string[] {
|
|
293
|
+
const minWidth = 76;
|
|
294
|
+
const maxWidth = 96;
|
|
295
|
+
const boxWidth = Math.max(minWidth, Math.min(termWidth - 2, maxWidth));
|
|
296
|
+
const hChar = "─";
|
|
253
297
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
if (char === "m") inEscape = false;
|
|
259
|
-
} else if (currentWidth < maxWidth) {
|
|
260
|
-
truncated += char;
|
|
261
|
-
currentWidth++;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
298
|
+
// Bottom line with column separator (leftCol=26, rightCol=boxWidth-29)
|
|
299
|
+
const leftCol = 26;
|
|
300
|
+
const rightCol = boxWidth - leftCol - 3;
|
|
301
|
+
const bottomLine = dim(hChar.repeat(leftCol)) + dim("┴") + dim(hChar.repeat(rightCol));
|
|
264
302
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
return truncated;
|
|
303
|
+
const lines = renderWelcomeBox(this.data, termWidth, bottomLine);
|
|
304
|
+
lines.push(""); // Add empty line for spacing
|
|
305
|
+
return lines;
|
|
269
306
|
}
|
|
270
307
|
}
|
|
271
308
|
|
|
272
309
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
273
|
-
// Discovery
|
|
310
|
+
// Discovery functions
|
|
274
311
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
275
312
|
|
|
276
313
|
/**
|
|
@@ -285,7 +322,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
285
322
|
let skills = 0;
|
|
286
323
|
let promptTemplates = 0;
|
|
287
324
|
|
|
288
|
-
// Count AGENTS.md context files (check all locations pi-mono supports)
|
|
289
325
|
const agentsMdPaths = [
|
|
290
326
|
join(homeDir, ".pi", "agent", "AGENTS.md"),
|
|
291
327
|
join(homeDir, ".claude", "AGENTS.md"),
|
|
@@ -295,12 +331,9 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
295
331
|
];
|
|
296
332
|
|
|
297
333
|
for (const path of agentsMdPaths) {
|
|
298
|
-
if (existsSync(path))
|
|
299
|
-
contextFiles++;
|
|
300
|
-
}
|
|
334
|
+
if (existsSync(path)) contextFiles++;
|
|
301
335
|
}
|
|
302
336
|
|
|
303
|
-
// Count extensions - both standalone .ts files and directories with index.ts
|
|
304
337
|
const extensionDirs = [
|
|
305
338
|
join(homeDir, ".pi", "agent", "extensions"),
|
|
306
339
|
join(cwd, "extensions"),
|
|
@@ -318,7 +351,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
318
351
|
const stats = statSync(entryPath);
|
|
319
352
|
|
|
320
353
|
if (stats.isDirectory()) {
|
|
321
|
-
// Directory extension - check for index.ts or package.json
|
|
322
354
|
if (existsSync(join(entryPath, "index.ts")) || existsSync(join(entryPath, "package.json"))) {
|
|
323
355
|
if (!countedExtensions.has(entry)) {
|
|
324
356
|
countedExtensions.add(entry);
|
|
@@ -326,7 +358,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
326
358
|
}
|
|
327
359
|
}
|
|
328
360
|
} else if (entry.endsWith(".ts") && !entry.startsWith(".")) {
|
|
329
|
-
// Standalone .ts file extension
|
|
330
361
|
const name = basename(entry, ".ts");
|
|
331
362
|
if (!countedExtensions.has(name)) {
|
|
332
363
|
countedExtensions.add(name);
|
|
@@ -338,7 +369,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
338
369
|
}
|
|
339
370
|
}
|
|
340
371
|
|
|
341
|
-
// Count skills
|
|
342
372
|
const skillDirs = [
|
|
343
373
|
join(homeDir, ".pi", "agent", "skills"),
|
|
344
374
|
join(cwd, ".pi", "skills"),
|
|
@@ -355,7 +385,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
355
385
|
const entryPath = join(dir, entry);
|
|
356
386
|
try {
|
|
357
387
|
if (statSync(entryPath).isDirectory()) {
|
|
358
|
-
// Check for SKILL.md
|
|
359
388
|
if (existsSync(join(entryPath, "SKILL.md"))) {
|
|
360
389
|
if (!countedSkills.has(entry)) {
|
|
361
390
|
countedSkills.add(entry);
|
|
@@ -369,7 +398,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
369
398
|
}
|
|
370
399
|
}
|
|
371
400
|
|
|
372
|
-
// Count prompt templates (slash commands) - recursively find .md files
|
|
373
401
|
const templateDirs = [
|
|
374
402
|
join(homeDir, ".pi", "agent", "commands"),
|
|
375
403
|
join(homeDir, ".claude", "commands"),
|
|
@@ -388,7 +416,6 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
388
416
|
try {
|
|
389
417
|
const stats = statSync(entryPath);
|
|
390
418
|
if (stats.isDirectory()) {
|
|
391
|
-
// Recurse into subdirectories
|
|
392
419
|
countTemplatesInDir(entryPath);
|
|
393
420
|
} else if (entry.endsWith(".md")) {
|
|
394
421
|
const name = basename(entry, ".md");
|
|
@@ -411,12 +438,10 @@ export function discoverLoadedCounts(): LoadedCounts {
|
|
|
411
438
|
|
|
412
439
|
/**
|
|
413
440
|
* Get recent sessions from the sessions directory.
|
|
414
|
-
* pi-mono stores sessions in subdirectories: ~/.pi/agent/sessions/<project-path>/*.jsonl
|
|
415
441
|
*/
|
|
416
442
|
export function getRecentSessions(maxCount: number = 3): RecentSession[] {
|
|
417
443
|
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
418
444
|
|
|
419
|
-
// Try multiple possible session directories (pi-mono uses ~/.pi/agent/sessions/)
|
|
420
445
|
const sessionsDirs = [
|
|
421
446
|
join(homeDir, ".pi", "agent", "sessions"),
|
|
422
447
|
join(homeDir, ".pi", "sessions"),
|
|
@@ -433,15 +458,11 @@ export function getRecentSessions(maxCount: number = 3): RecentSession[] {
|
|
|
433
458
|
try {
|
|
434
459
|
const stats = statSync(entryPath);
|
|
435
460
|
if (stats.isDirectory()) {
|
|
436
|
-
// Recurse into subdirectories (project folders)
|
|
437
461
|
scanDir(entryPath);
|
|
438
462
|
} else if (entry.endsWith(".jsonl")) {
|
|
439
|
-
// Extract project name from parent directory
|
|
440
463
|
const parentName = basename(dir);
|
|
441
|
-
// Clean up the directory name (it's URL-encoded path like --Users-nicobailon-...)
|
|
442
464
|
let projectName = parentName;
|
|
443
465
|
if (parentName.startsWith("--")) {
|
|
444
|
-
// Extract last path segment
|
|
445
466
|
const parts = parentName.split("-").filter(p => p);
|
|
446
467
|
projectName = parts[parts.length - 1] || parentName;
|
|
447
468
|
}
|
|
@@ -458,7 +479,6 @@ export function getRecentSessions(maxCount: number = 3): RecentSession[] {
|
|
|
458
479
|
|
|
459
480
|
if (sessions.length === 0) return [];
|
|
460
481
|
|
|
461
|
-
// Sort by modification time (newest first) and deduplicate by name
|
|
462
482
|
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
463
483
|
|
|
464
484
|
const seen = new Set<string>();
|
|
@@ -470,7 +490,6 @@ export function getRecentSessions(maxCount: number = 3): RecentSession[] {
|
|
|
470
490
|
}
|
|
471
491
|
}
|
|
472
492
|
|
|
473
|
-
// Format time ago
|
|
474
493
|
const now = Date.now();
|
|
475
494
|
return uniqueSessions.slice(0, maxCount).map(s => ({
|
|
476
495
|
name: s.name.length > 20 ? s.name.slice(0, 17) + "…" : s.name,
|