pi-powerline-footer 0.2.4 → 0.2.6
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 +18 -0
- package/README.md +4 -2
- package/index.ts +85 -2
- package/package.json +1 -1
- package/welcome.ts +491 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.2.6] - 2026-01-16
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Removed invalid `?` keyboard shortcut tip, replaced with `Shift+Tab` for cycling thinking level
|
|
7
|
+
|
|
8
|
+
## [0.2.5] - 2026-01-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- **Welcome overlay** — Branded "pi agent" splash screen shown as centered overlay on startup
|
|
12
|
+
- Two-column boxed layout with gradient PI logo (magenta → cyan)
|
|
13
|
+
- Shows current model name and provider
|
|
14
|
+
- Keyboard tips section (?, /, !)
|
|
15
|
+
- Loaded counts: context files (AGENTS.md), extensions, skills, and prompt templates
|
|
16
|
+
- Recent sessions list (up to 3, with time ago)
|
|
17
|
+
- Auto-dismisses after 30 seconds or on any key press
|
|
18
|
+
- Version now reads from package.json instead of being hardcoded
|
|
19
|
+
- Context file discovery now checks `.claude/AGENTS.md` paths (matching pi-mono)
|
|
20
|
+
|
|
3
21
|
## [0.2.4] - 2026-01-15
|
|
4
22
|
|
|
5
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# pi-powerline-footer
|
|
2
2
|
|
|
3
|
-
A powerline-style status bar extension for [pi](https://github.com/badlogic/pi-mono), the coding agent. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
3
|
+
A powerline-style status bar and welcome header extension for [pi](https://github.com/badlogic/pi-mono), the coding agent. Inspired by [oh-my-pi](https://github.com/can1357/oh-my-pi).
|
|
4
4
|
|
|
5
|
-
<img width="
|
|
5
|
+
<img width="1261" height="817" alt="Image" src="https://github.com/user-attachments/assets/4cc43320-3fb8-4503-b857-69dffa7028f2" />
|
|
6
6
|
|
|
7
7
|
## Features
|
|
8
8
|
|
|
9
|
+
**Welcome overlay** — Branded splash screen shown as centered overlay on startup. Shows gradient logo, model info, keyboard tips, loaded AGENTS.md/extensions/skills/templates counts, and recent sessions. Auto-dismisses after 30 seconds or on any key press.
|
|
10
|
+
|
|
9
11
|
**Rounded box design** — Status renders directly in the editor's top border, not as a separate footer.
|
|
10
12
|
|
|
11
13
|
**Live thinking level indicator** — Shows current thinking level (`thinking:off`, `thinking:med`, etc.) with color-coded gradient. High and xhigh levels get a rainbow shimmer effect inspired by Claude Code's ultrathink.
|
package/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
5
|
|
|
5
6
|
import type { SegmentContext, StatusLinePreset } from "./types.js";
|
|
6
7
|
import { getPreset, PRESETS } from "./presets.js";
|
|
@@ -8,6 +9,7 @@ import { getSeparator } from "./separators.js";
|
|
|
8
9
|
import { renderSegment } from "./segments.js";
|
|
9
10
|
import { getGitStatus, invalidateGitStatus, invalidateGitBranch } from "./git-status.js";
|
|
10
11
|
import { ansi, getFgAnsiCode } from "./colors.js";
|
|
12
|
+
import { WelcomeComponent, discoverLoadedCounts, getRecentSessions } from "./welcome.js";
|
|
11
13
|
|
|
12
14
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
15
|
// Configuration
|
|
@@ -82,6 +84,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
82
84
|
|
|
83
85
|
if (enabled && ctx.hasUI) {
|
|
84
86
|
setupCustomEditor(ctx);
|
|
87
|
+
setupWelcomeOverlay(ctx);
|
|
85
88
|
}
|
|
86
89
|
});
|
|
87
90
|
|
|
@@ -148,14 +151,14 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
148
151
|
enabled = !enabled;
|
|
149
152
|
if (enabled) {
|
|
150
153
|
setupCustomEditor(ctx);
|
|
151
|
-
ctx.ui.notify("Powerline
|
|
154
|
+
ctx.ui.notify("Powerline enabled", "info");
|
|
152
155
|
} else {
|
|
153
156
|
// setFooter(undefined) internally calls the old footer's dispose()
|
|
154
157
|
ctx.ui.setEditorComponent(undefined);
|
|
155
158
|
ctx.ui.setFooter(undefined);
|
|
156
159
|
footerDataRef = null;
|
|
157
160
|
tuiRef = null;
|
|
158
|
-
ctx.ui.notify("
|
|
161
|
+
ctx.ui.notify("Defaults restored", "info");
|
|
159
162
|
}
|
|
160
163
|
return;
|
|
161
164
|
}
|
|
@@ -409,4 +412,84 @@ export default function powerlineFooter(pi: ExtensionAPI) {
|
|
|
409
412
|
});
|
|
410
413
|
});
|
|
411
414
|
}
|
|
415
|
+
|
|
416
|
+
function setupWelcomeOverlay(ctx: any) {
|
|
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
|
|
426
|
+
const modelName = ctx.model?.name || ctx.model?.id || "No model";
|
|
427
|
+
const providerName = ctx.model?.provider || "Unknown";
|
|
428
|
+
|
|
429
|
+
// Discover loaded counts and recent sessions
|
|
430
|
+
const loadedCounts = discoverLoadedCounts();
|
|
431
|
+
const recentSessions = getRecentSessions(3);
|
|
432
|
+
|
|
433
|
+
// Small delay to let pi-mono finish initialization before showing overlay
|
|
434
|
+
setTimeout(() => {
|
|
435
|
+
// Show welcome as an overlay that dismisses on any key or after timeout
|
|
436
|
+
ctx.ui.custom(
|
|
437
|
+
(tui: any, _theme: any, _keybindings: any, done: (result: void) => void) => {
|
|
438
|
+
const welcome = new WelcomeComponent(
|
|
439
|
+
version,
|
|
440
|
+
modelName,
|
|
441
|
+
providerName,
|
|
442
|
+
recentSessions,
|
|
443
|
+
loadedCounts,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
let countdown = 30;
|
|
447
|
+
let dismissed = false;
|
|
448
|
+
|
|
449
|
+
const dismiss = () => {
|
|
450
|
+
if (dismissed) return;
|
|
451
|
+
dismissed = true;
|
|
452
|
+
clearInterval(interval);
|
|
453
|
+
done();
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
// Update countdown every second
|
|
457
|
+
const interval = setInterval(() => {
|
|
458
|
+
if (dismissed) return;
|
|
459
|
+
countdown--;
|
|
460
|
+
welcome.setCountdown(countdown);
|
|
461
|
+
tui.requestRender();
|
|
462
|
+
|
|
463
|
+
if (countdown <= 0) {
|
|
464
|
+
dismiss();
|
|
465
|
+
}
|
|
466
|
+
}, 1000);
|
|
467
|
+
|
|
468
|
+
// Create a focusable wrapper component
|
|
469
|
+
// Must have 'focused' property for TUI to recognize it as focusable
|
|
470
|
+
return {
|
|
471
|
+
focused: false, // TUI sets this to true when focused
|
|
472
|
+
invalidate: () => welcome.invalidate(),
|
|
473
|
+
render: (width: number) => welcome.render(width),
|
|
474
|
+
handleInput: (_data: string) => {
|
|
475
|
+
dismiss();
|
|
476
|
+
},
|
|
477
|
+
dispose: () => {
|
|
478
|
+
dismissed = true;
|
|
479
|
+
clearInterval(interval);
|
|
480
|
+
},
|
|
481
|
+
};
|
|
482
|
+
},
|
|
483
|
+
{
|
|
484
|
+
overlay: true,
|
|
485
|
+
overlayOptions: () => ({
|
|
486
|
+
verticalAlign: "center",
|
|
487
|
+
horizontalAlign: "center",
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
490
|
+
).catch(() => {
|
|
491
|
+
// Dismissed, ignore
|
|
492
|
+
});
|
|
493
|
+
}, 100); // Small delay to let init complete
|
|
494
|
+
}
|
|
412
495
|
}
|
package/package.json
CHANGED
package/welcome.ts
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
import { readdirSync, existsSync, statSync } from "node:fs";
|
|
2
|
+
import { join, basename } from "node:path";
|
|
3
|
+
import type { Component } from "@mariozechner/pi-tui";
|
|
4
|
+
import { visibleWidth } from "@mariozechner/pi-tui";
|
|
5
|
+
import { ansi, fgOnly, getFgAnsiCode } from "./colors.js";
|
|
6
|
+
|
|
7
|
+
export interface RecentSession {
|
|
8
|
+
name: string;
|
|
9
|
+
timeAgo: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface LoadedCounts {
|
|
13
|
+
contextFiles: number;
|
|
14
|
+
extensions: number;
|
|
15
|
+
skills: number;
|
|
16
|
+
promptTemplates: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Welcome overlay component for pi agent.
|
|
21
|
+
* Displays a branded splash screen with logo, tips, and loaded counts.
|
|
22
|
+
* Includes a countdown timer for auto-dismiss.
|
|
23
|
+
*/
|
|
24
|
+
export class WelcomeComponent implements Component {
|
|
25
|
+
private version: string;
|
|
26
|
+
private modelName: string;
|
|
27
|
+
private providerName: string;
|
|
28
|
+
private recentSessions: RecentSession[];
|
|
29
|
+
private loadedCounts: LoadedCounts;
|
|
30
|
+
private countdown: number = 30;
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
version: string,
|
|
34
|
+
modelName: string,
|
|
35
|
+
providerName: string,
|
|
36
|
+
recentSessions: RecentSession[] = [],
|
|
37
|
+
loadedCounts: LoadedCounts = { contextFiles: 0, extensions: 0, skills: 0, promptTemplates: 0 },
|
|
38
|
+
) {
|
|
39
|
+
this.version = version;
|
|
40
|
+
this.modelName = modelName;
|
|
41
|
+
this.providerName = providerName;
|
|
42
|
+
this.recentSessions = recentSessions;
|
|
43
|
+
this.loadedCounts = loadedCounts;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setCountdown(seconds: number): void {
|
|
47
|
+
this.countdown = seconds;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
invalidate(): void {}
|
|
51
|
+
|
|
52
|
+
render(termWidth: number): string[] {
|
|
53
|
+
// Box dimensions - responsive with min/max
|
|
54
|
+
const minWidth = 76;
|
|
55
|
+
const maxWidth = 96;
|
|
56
|
+
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
|
+
|
|
104
|
+
if (contextFiles > 0 || extensions > 0 || skills > 0 || promptTemplates > 0) {
|
|
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
|
|
163
|
+
const countdownText = ` Press any key to continue (${this.countdown}s) `;
|
|
164
|
+
const countdownStyled = this.dim(countdownText);
|
|
165
|
+
const bottomContentWidth = boxWidth - 2; // -2 for corners
|
|
166
|
+
const countdownVisLen = visibleWidth(countdownText);
|
|
167
|
+
const leftPad = Math.floor((bottomContentWidth - countdownVisLen) / 2);
|
|
168
|
+
const rightPad = bottomContentWidth - countdownVisLen - leftPad;
|
|
169
|
+
|
|
170
|
+
lines.push(
|
|
171
|
+
bl +
|
|
172
|
+
this.dim(hChar.repeat(Math.max(0, leftPad))) +
|
|
173
|
+
countdownStyled +
|
|
174
|
+
this.dim(hChar.repeat(Math.max(0, rightPad))) +
|
|
175
|
+
br
|
|
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", "✓");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Center text within a given width */
|
|
194
|
+
private centerText(text: string, width: number): string {
|
|
195
|
+
const visLen = visibleWidth(text);
|
|
196
|
+
if (visLen > width) {
|
|
197
|
+
return this.truncateToWidth(text, width);
|
|
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
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Apply magenta→cyan gradient to a string */
|
|
208
|
+
private gradientLine(line: string): string {
|
|
209
|
+
const colors = [
|
|
210
|
+
"\x1b[38;5;199m", // bright magenta
|
|
211
|
+
"\x1b[38;5;171m", // magenta-purple
|
|
212
|
+
"\x1b[38;5;135m", // purple
|
|
213
|
+
"\x1b[38;5;99m", // purple-blue
|
|
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;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Fit string to exact width with ANSI-aware truncation/padding */
|
|
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
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Truncate string to width, preserving ANSI codes */
|
|
247
|
+
private truncateToWidth(str: string, width: number): string {
|
|
248
|
+
const ellipsis = "…";
|
|
249
|
+
const maxWidth = Math.max(0, width - 1);
|
|
250
|
+
let truncated = "";
|
|
251
|
+
let currentWidth = 0;
|
|
252
|
+
let inEscape = false;
|
|
253
|
+
|
|
254
|
+
for (const char of str) {
|
|
255
|
+
if (char === "\x1b") inEscape = true;
|
|
256
|
+
if (inEscape) {
|
|
257
|
+
truncated += char;
|
|
258
|
+
if (char === "m") inEscape = false;
|
|
259
|
+
} else if (currentWidth < maxWidth) {
|
|
260
|
+
truncated += char;
|
|
261
|
+
currentWidth++;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (visibleWidth(str) > width) {
|
|
266
|
+
return truncated + ellipsis;
|
|
267
|
+
}
|
|
268
|
+
return truncated;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
273
|
+
// Discovery helpers
|
|
274
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Discover loaded counts by scanning filesystem.
|
|
278
|
+
*/
|
|
279
|
+
export function discoverLoadedCounts(): LoadedCounts {
|
|
280
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
281
|
+
const cwd = process.cwd();
|
|
282
|
+
|
|
283
|
+
let contextFiles = 0;
|
|
284
|
+
let extensions = 0;
|
|
285
|
+
let skills = 0;
|
|
286
|
+
let promptTemplates = 0;
|
|
287
|
+
|
|
288
|
+
// Count AGENTS.md context files (check all locations pi-mono supports)
|
|
289
|
+
const agentsMdPaths = [
|
|
290
|
+
join(homeDir, ".pi", "agent", "AGENTS.md"),
|
|
291
|
+
join(homeDir, ".claude", "AGENTS.md"),
|
|
292
|
+
join(cwd, "AGENTS.md"),
|
|
293
|
+
join(cwd, ".pi", "AGENTS.md"),
|
|
294
|
+
join(cwd, ".claude", "AGENTS.md"),
|
|
295
|
+
];
|
|
296
|
+
|
|
297
|
+
for (const path of agentsMdPaths) {
|
|
298
|
+
if (existsSync(path)) {
|
|
299
|
+
contextFiles++;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Count extensions - both standalone .ts files and directories with index.ts
|
|
304
|
+
const extensionDirs = [
|
|
305
|
+
join(homeDir, ".pi", "agent", "extensions"),
|
|
306
|
+
join(cwd, "extensions"),
|
|
307
|
+
join(cwd, ".pi", "extensions"),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
const countedExtensions = new Set<string>();
|
|
311
|
+
|
|
312
|
+
for (const dir of extensionDirs) {
|
|
313
|
+
if (existsSync(dir)) {
|
|
314
|
+
try {
|
|
315
|
+
const entries = readdirSync(dir);
|
|
316
|
+
for (const entry of entries) {
|
|
317
|
+
const entryPath = join(dir, entry);
|
|
318
|
+
const stats = statSync(entryPath);
|
|
319
|
+
|
|
320
|
+
if (stats.isDirectory()) {
|
|
321
|
+
// Directory extension - check for index.ts or package.json
|
|
322
|
+
if (existsSync(join(entryPath, "index.ts")) || existsSync(join(entryPath, "package.json"))) {
|
|
323
|
+
if (!countedExtensions.has(entry)) {
|
|
324
|
+
countedExtensions.add(entry);
|
|
325
|
+
extensions++;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
} else if (entry.endsWith(".ts") && !entry.startsWith(".")) {
|
|
329
|
+
// Standalone .ts file extension
|
|
330
|
+
const name = basename(entry, ".ts");
|
|
331
|
+
if (!countedExtensions.has(name)) {
|
|
332
|
+
countedExtensions.add(name);
|
|
333
|
+
extensions++;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
} catch {}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Count skills
|
|
342
|
+
const skillDirs = [
|
|
343
|
+
join(homeDir, ".pi", "agent", "skills"),
|
|
344
|
+
join(cwd, ".pi", "skills"),
|
|
345
|
+
join(cwd, "skills"),
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
const countedSkills = new Set<string>();
|
|
349
|
+
|
|
350
|
+
for (const dir of skillDirs) {
|
|
351
|
+
if (existsSync(dir)) {
|
|
352
|
+
try {
|
|
353
|
+
const entries = readdirSync(dir);
|
|
354
|
+
for (const entry of entries) {
|
|
355
|
+
const entryPath = join(dir, entry);
|
|
356
|
+
try {
|
|
357
|
+
if (statSync(entryPath).isDirectory()) {
|
|
358
|
+
// Check for SKILL.md
|
|
359
|
+
if (existsSync(join(entryPath, "SKILL.md"))) {
|
|
360
|
+
if (!countedSkills.has(entry)) {
|
|
361
|
+
countedSkills.add(entry);
|
|
362
|
+
skills++;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
} catch {}
|
|
367
|
+
}
|
|
368
|
+
} catch {}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Count prompt templates (slash commands) - recursively find .md files
|
|
373
|
+
const templateDirs = [
|
|
374
|
+
join(homeDir, ".pi", "agent", "commands"),
|
|
375
|
+
join(homeDir, ".claude", "commands"),
|
|
376
|
+
join(cwd, ".pi", "commands"),
|
|
377
|
+
join(cwd, ".claude", "commands"),
|
|
378
|
+
];
|
|
379
|
+
|
|
380
|
+
const countedTemplates = new Set<string>();
|
|
381
|
+
|
|
382
|
+
function countTemplatesInDir(dir: string) {
|
|
383
|
+
if (!existsSync(dir)) return;
|
|
384
|
+
try {
|
|
385
|
+
const entries = readdirSync(dir);
|
|
386
|
+
for (const entry of entries) {
|
|
387
|
+
const entryPath = join(dir, entry);
|
|
388
|
+
try {
|
|
389
|
+
const stats = statSync(entryPath);
|
|
390
|
+
if (stats.isDirectory()) {
|
|
391
|
+
// Recurse into subdirectories
|
|
392
|
+
countTemplatesInDir(entryPath);
|
|
393
|
+
} else if (entry.endsWith(".md")) {
|
|
394
|
+
const name = basename(entry, ".md");
|
|
395
|
+
if (!countedTemplates.has(name)) {
|
|
396
|
+
countedTemplates.add(name);
|
|
397
|
+
promptTemplates++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
} catch {}
|
|
401
|
+
}
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const dir of templateDirs) {
|
|
406
|
+
countTemplatesInDir(dir);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return { contextFiles, extensions, skills, promptTemplates };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Get recent sessions from the sessions directory.
|
|
414
|
+
* pi-mono stores sessions in subdirectories: ~/.pi/agent/sessions/<project-path>/*.jsonl
|
|
415
|
+
*/
|
|
416
|
+
export function getRecentSessions(maxCount: number = 3): RecentSession[] {
|
|
417
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || "";
|
|
418
|
+
|
|
419
|
+
// Try multiple possible session directories (pi-mono uses ~/.pi/agent/sessions/)
|
|
420
|
+
const sessionsDirs = [
|
|
421
|
+
join(homeDir, ".pi", "agent", "sessions"),
|
|
422
|
+
join(homeDir, ".pi", "sessions"),
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
const sessions: { name: string; mtime: number }[] = [];
|
|
426
|
+
|
|
427
|
+
function scanDir(dir: string) {
|
|
428
|
+
if (!existsSync(dir)) return;
|
|
429
|
+
try {
|
|
430
|
+
const entries = readdirSync(dir);
|
|
431
|
+
for (const entry of entries) {
|
|
432
|
+
const entryPath = join(dir, entry);
|
|
433
|
+
try {
|
|
434
|
+
const stats = statSync(entryPath);
|
|
435
|
+
if (stats.isDirectory()) {
|
|
436
|
+
// Recurse into subdirectories (project folders)
|
|
437
|
+
scanDir(entryPath);
|
|
438
|
+
} else if (entry.endsWith(".jsonl")) {
|
|
439
|
+
// Extract project name from parent directory
|
|
440
|
+
const parentName = basename(dir);
|
|
441
|
+
// Clean up the directory name (it's URL-encoded path like --Users-nicobailon-...)
|
|
442
|
+
let projectName = parentName;
|
|
443
|
+
if (parentName.startsWith("--")) {
|
|
444
|
+
// Extract last path segment
|
|
445
|
+
const parts = parentName.split("-").filter(p => p);
|
|
446
|
+
projectName = parts[parts.length - 1] || parentName;
|
|
447
|
+
}
|
|
448
|
+
sessions.push({ name: projectName, mtime: stats.mtimeMs });
|
|
449
|
+
}
|
|
450
|
+
} catch {}
|
|
451
|
+
}
|
|
452
|
+
} catch {}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
for (const sessionsDir of sessionsDirs) {
|
|
456
|
+
scanDir(sessionsDir);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (sessions.length === 0) return [];
|
|
460
|
+
|
|
461
|
+
// Sort by modification time (newest first) and deduplicate by name
|
|
462
|
+
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
463
|
+
|
|
464
|
+
const seen = new Set<string>();
|
|
465
|
+
const uniqueSessions: typeof sessions = [];
|
|
466
|
+
for (const s of sessions) {
|
|
467
|
+
if (!seen.has(s.name)) {
|
|
468
|
+
seen.add(s.name);
|
|
469
|
+
uniqueSessions.push(s);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Format time ago
|
|
474
|
+
const now = Date.now();
|
|
475
|
+
return uniqueSessions.slice(0, maxCount).map(s => ({
|
|
476
|
+
name: s.name.length > 20 ? s.name.slice(0, 17) + "…" : s.name,
|
|
477
|
+
timeAgo: formatTimeAgo(now - s.mtime),
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function formatTimeAgo(ms: number): string {
|
|
482
|
+
const seconds = Math.floor(ms / 1000);
|
|
483
|
+
const minutes = Math.floor(seconds / 60);
|
|
484
|
+
const hours = Math.floor(minutes / 60);
|
|
485
|
+
const days = Math.floor(hours / 24);
|
|
486
|
+
|
|
487
|
+
if (days > 0) return `${days}d ago`;
|
|
488
|
+
if (hours > 0) return `${hours}h ago`;
|
|
489
|
+
if (minutes > 0) return `${minutes}m ago`;
|
|
490
|
+
return "just now";
|
|
491
|
+
}
|