pi-powerline-footer 0.2.4 → 0.2.5

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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.5] - 2026-01-16
4
+
5
+ ### Added
6
+ - **Welcome overlay** — Branded "pi agent" splash screen shown as centered overlay on startup
7
+ - Two-column boxed layout with gradient PI logo (magenta → cyan)
8
+ - Shows current model name and provider
9
+ - Keyboard tips section (?, /, !)
10
+ - Loaded counts: context files (AGENTS.md), extensions, skills, and prompt templates
11
+ - Recent sessions list (up to 3, with time ago)
12
+ - Auto-dismisses after 30 seconds or on any key press
13
+ - Version now reads from package.json instead of being hardcoded
14
+ - Context file discovery now checks `.claude/AGENTS.md` paths (matching pi-mono)
15
+
3
16
  ## [0.2.4] - 2026-01-15
4
17
 
5
18
  ### 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="1555" height="171" alt="image" src="https://github.com/user-attachments/assets/7bfc8a5a-29b5-478e-b9c8-c462c032a840" />
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 status enabled", "info");
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("Default editor restored", "info");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-powerline-footer",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "Powerline-style status bar extension for pi coding agent",
5
5
  "type": "module",
6
6
  "bin": {
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 keyboard shortcuts`,
125
+ ` ${this.dim("/")} for commands`,
126
+ ` ${this.dim("!")} to run bash`,
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
+ }