pi-powerline-footer 0.2.6 → 0.2.9

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.
Files changed (4) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/index.ts +124 -40
  3. package/package.json +1 -1
  4. package/welcome.ts +250 -231
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.9] - 2026-01-17
4
+
5
+ ### Fixed
6
+ - Welcome overlay/header now dismisses when agent starts streaming (fixes `p "command"` case where welcome would briefly flash)
7
+ - Race condition where dismissal request could be lost due to 100ms setup delay in overlay
8
+
9
+ ## [0.2.8] - 2026-01-16
10
+
11
+ ### Changed
12
+ - `quietStartup: true` → shows welcome as header (dismisses on first input)
13
+ - `quietStartup: false` or not set → shows welcome as centered overlay (dismisses on key/timeout)
14
+ - Both modes use same two-column layout: logo, model info, tips, loaded counts, recent sessions
15
+ - Refactored welcome.ts to share rendering logic between header and overlay
16
+
17
+ ### Fixed
18
+ - `/powerline` toggle off now clears all custom UI (editor, footer, header)
19
+
3
20
  ## [0.2.6] - 2026-01-16
4
21
 
5
22
  ### 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,9 @@ 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
92
+ let welcomeOverlayShouldDismiss = false; // Track early dismissal request (before overlay setup completes)
74
93
 
75
94
  // Track session start
76
95
  pi.on("session_start", async (_event, ctx) => {
@@ -84,7 +103,12 @@ export default function powerlineFooter(pi: ExtensionAPI) {
84
103
 
85
104
  if (enabled && ctx.hasUI) {
86
105
  setupCustomEditor(ctx);
87
- setupWelcomeOverlay(ctx);
106
+ // quietStartup: true → compact header, otherwise → full overlay
107
+ if (isQuietStartup()) {
108
+ setupWelcomeHeader(ctx);
109
+ } else {
110
+ setupWelcomeOverlay(ctx);
111
+ }
88
112
  }
89
113
  });
90
114
 
@@ -131,14 +155,41 @@ export default function powerlineFooter(pi: ExtensionAPI) {
131
155
  });
132
156
 
133
157
  // Track streaming state (footer only shows status during streaming)
134
- pi.on("stream_start", async () => {
158
+ // Also dismiss welcome when agent starts responding (handles `p "command"` case)
159
+ pi.on("stream_start", async (_event, ctx) => {
135
160
  isStreaming = true;
161
+ if (dismissWelcomeOverlay) {
162
+ dismissWelcomeOverlay();
163
+ dismissWelcomeOverlay = null;
164
+ } else {
165
+ // Overlay not set up yet (100ms delay) - mark for immediate dismissal when it does
166
+ welcomeOverlayShouldDismiss = true;
167
+ }
168
+ if (welcomeHeaderActive) {
169
+ welcomeHeaderActive = false;
170
+ ctx.ui.setHeader(undefined);
171
+ }
136
172
  });
137
173
 
138
174
  pi.on("stream_end", async () => {
139
175
  isStreaming = false;
140
176
  });
141
177
 
178
+ // Dismiss welcome overlay/header on first user message
179
+ pi.on("user_message", async (_event, ctx) => {
180
+ if (dismissWelcomeOverlay) {
181
+ dismissWelcomeOverlay();
182
+ dismissWelcomeOverlay = null;
183
+ } else {
184
+ // Overlay not set up yet (100ms delay) - mark for immediate dismissal when it does
185
+ welcomeOverlayShouldDismiss = true;
186
+ }
187
+ if (welcomeHeaderActive) {
188
+ welcomeHeaderActive = false;
189
+ ctx.ui.setHeader(undefined);
190
+ }
191
+ });
192
+
142
193
  // Command to toggle/configure
143
194
  pi.registerCommand("powerline", {
144
195
  description: "Configure powerline status (toggle, preset)",
@@ -153,9 +204,10 @@ export default function powerlineFooter(pi: ExtensionAPI) {
153
204
  setupCustomEditor(ctx);
154
205
  ctx.ui.notify("Powerline enabled", "info");
155
206
  } else {
156
- // setFooter(undefined) internally calls the old footer's dispose()
207
+ // Clear all custom UI components
157
208
  ctx.ui.setEditorComponent(undefined);
158
209
  ctx.ui.setFooter(undefined);
210
+ ctx.ui.setHeader(undefined);
159
211
  footerDataRef = null;
160
212
  tuiRef = null;
161
213
  ctx.ui.notify("Defaults restored", "info");
@@ -249,6 +301,25 @@ export default function powerlineFooter(pi: ExtensionAPI) {
249
301
  // Create custom editor that overrides render for status in top border
250
302
  const editor = new CustomEditor(tui, theme, keybindings);
251
303
 
304
+ // Override handleInput to dismiss welcome on first keypress
305
+ const originalHandleInput = editor.handleInput.bind(editor);
306
+ editor.handleInput = (data: string) => {
307
+ // Dismiss welcome overlay/header on first keypress
308
+ if (dismissWelcomeOverlay) {
309
+ const dismiss = dismissWelcomeOverlay;
310
+ dismissWelcomeOverlay = null;
311
+ setTimeout(dismiss, 0);
312
+ } else {
313
+ // Overlay not set up yet (100ms delay) - mark for immediate dismissal when it does
314
+ welcomeOverlayShouldDismiss = true;
315
+ }
316
+ if (welcomeHeaderActive) {
317
+ welcomeHeaderActive = false;
318
+ ctx.ui.setHeader(undefined);
319
+ }
320
+ originalHandleInput(data);
321
+ };
322
+
252
323
  // Store original render
253
324
  const originalRender = editor.render.bind(editor);
254
325
 
@@ -299,10 +370,9 @@ export default function powerlineFooter(pi: ExtensionAPI) {
299
370
  } else {
300
371
  // Status too wide - truncate by removing segments from the end
301
372
  // Build progressively shorter content until it fits
302
- const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
303
373
  let truncatedContent = "";
304
374
 
305
- for (let numSegments = allSegments.length - 1; numSegments >= 1; numSegments--) {
375
+ for (let numSegments = presetDef.leftSegments.length - 1; numSegments >= 1; numSegments--) {
306
376
  const limitedPreset = {
307
377
  ...presetDef,
308
378
  leftSegments: presetDef.leftSegments.slice(0, numSegments),
@@ -368,8 +438,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
368
438
  return {
369
439
  dispose: unsub,
370
440
  invalidate() {
371
- // Re-render when thinking level or other settings change
372
- tui.requestRender();
441
+ // No cache to clear - render is always fresh
373
442
  },
374
443
  render(width: number): string[] {
375
444
  // Only show status in footer during streaming (editor hidden)
@@ -388,10 +457,9 @@ export default function powerlineFooter(pi: ExtensionAPI) {
388
457
  return [statusContent + " ".repeat(width - statusWidth)];
389
458
  } else {
390
459
  // Truncate by removing segments (same logic as editor)
391
- const allSegments = [...presetDef.leftSegments, ...presetDef.rightSegments];
392
460
  let truncatedContent = "";
393
461
 
394
- for (let numSegments = allSegments.length - 1; numSegments >= 1; numSegments--) {
462
+ for (let numSegments = presetDef.leftSegments.length - 1; numSegments >= 1; numSegments--) {
395
463
  const limitedPreset = {
396
464
  ...presetDef,
397
465
  leftSegments: presetDef.leftSegments.slice(0, numSegments),
@@ -413,30 +481,45 @@ export default function powerlineFooter(pi: ExtensionAPI) {
413
481
  });
414
482
  }
415
483
 
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
484
+ function setupWelcomeHeader(ctx: any) {
426
485
  const modelName = ctx.model?.name || ctx.model?.id || "No model";
427
486
  const providerName = ctx.model?.provider || "Unknown";
487
+ const loadedCounts = discoverLoadedCounts();
488
+ const recentSessions = getRecentSessions(3);
428
489
 
429
- // Discover loaded counts and recent sessions
490
+ const header = new WelcomeHeader(modelName, providerName, recentSessions, loadedCounts);
491
+ welcomeHeaderActive = true; // Will be cleared on first user input
492
+
493
+ ctx.ui.setHeader((_tui: any, _theme: any) => {
494
+ return {
495
+ render(width: number): string[] {
496
+ return header.render(width);
497
+ },
498
+ invalidate() {
499
+ header.invalidate();
500
+ },
501
+ };
502
+ });
503
+ }
504
+
505
+ function setupWelcomeOverlay(ctx: any) {
506
+ const modelName = ctx.model?.name || ctx.model?.id || "No model";
507
+ const providerName = ctx.model?.provider || "Unknown";
430
508
  const loadedCounts = discoverLoadedCounts();
431
509
  const recentSessions = getRecentSessions(3);
432
510
 
433
- // Small delay to let pi-mono finish initialization before showing overlay
511
+ // Small delay to let pi-mono finish initialization
434
512
  setTimeout(() => {
435
- // Show welcome as an overlay that dismisses on any key or after timeout
513
+ // Skip overlay entirely if dismissal was requested during the delay
514
+ // (e.g., `p "command"` triggers stream_start before this runs)
515
+ if (welcomeOverlayShouldDismiss) {
516
+ welcomeOverlayShouldDismiss = false;
517
+ return;
518
+ }
519
+
436
520
  ctx.ui.custom(
437
521
  (tui: any, _theme: any, _keybindings: any, done: (result: void) => void) => {
438
522
  const welcome = new WelcomeComponent(
439
- version,
440
523
  modelName,
441
524
  providerName,
442
525
  recentSessions,
@@ -450,30 +533,33 @@ export default function powerlineFooter(pi: ExtensionAPI) {
450
533
  if (dismissed) return;
451
534
  dismissed = true;
452
535
  clearInterval(interval);
536
+ dismissWelcomeOverlay = null;
453
537
  done();
454
538
  };
455
539
 
456
- // Update countdown every second
540
+ // Store dismiss callback so user_message/keypress can trigger it
541
+ dismissWelcomeOverlay = dismiss;
542
+
543
+ // Double-check: dismissal might have been requested between the outer check
544
+ // and this callback running
545
+ if (welcomeOverlayShouldDismiss) {
546
+ welcomeOverlayShouldDismiss = false;
547
+ dismiss();
548
+ }
549
+
457
550
  const interval = setInterval(() => {
458
551
  if (dismissed) return;
459
552
  countdown--;
460
553
  welcome.setCountdown(countdown);
461
554
  tui.requestRender();
462
-
463
- if (countdown <= 0) {
464
- dismiss();
465
- }
555
+ if (countdown <= 0) dismiss();
466
556
  }, 1000);
467
557
 
468
- // Create a focusable wrapper component
469
- // Must have 'focused' property for TUI to recognize it as focusable
470
558
  return {
471
- focused: false, // TUI sets this to true when focused
559
+ focused: false,
472
560
  invalidate: () => welcome.invalidate(),
473
561
  render: (width: number) => welcome.render(width),
474
- handleInput: (_data: string) => {
475
- dismiss();
476
- },
562
+ handleInput: (_data: string) => dismiss(),
477
563
  dispose: () => {
478
564
  dismissed = true;
479
565
  clearInterval(interval);
@@ -487,9 +573,7 @@ export default function powerlineFooter(pi: ExtensionAPI) {
487
573
  horizontalAlign: "center",
488
574
  }),
489
575
  },
490
- ).catch(() => {
491
- // Dismissed, ignore
492
- });
493
- }, 100); // Small delay to let init complete
576
+ ).catch(() => {});
577
+ }, 100);
494
578
  }
495
579
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-powerline-footer",
3
- "version": "0.2.6",
3
+ "version": "0.2.9",
4
4
  "description": "Powerline-style status bar extension for pi coding agent",
5
5
  "type": "module",
6
6
  "bin": {
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 version: string;
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.version = version;
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
- 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
258
+ // Bottom line with countdown
163
259
  const countdownText = ` Press any key to continue (${this.countdown}s) `;
164
- const countdownStyled = this.dim(countdownText);
165
- const bottomContentWidth = boxWidth - 2; // -2 for corners
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
- lines.push(
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
- 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", "✓");
268
+ dim(hChar.repeat(Math.max(0, rightPad)));
269
+
270
+ return renderWelcomeBox(this.data, termWidth, bottomLine);
191
271
  }
272
+ }
192
273
 
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
- }
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
- /** 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;
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
- /** 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
- }
290
+ invalidate(): void {}
245
291
 
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;
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
- 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
- }
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
- if (visibleWidth(str) > width) {
266
- return truncated + ellipsis;
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 helpers
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,