pi-powerline-footer 0.2.5 → 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.
Files changed (4) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/index.ts +90 -39
  3. package/package.json +1 -1
  4. package/welcome.ts +250 -231
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
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
+
14
+ ## [0.2.6] - 2026-01-16
15
+
16
+ ### Fixed
17
+ - Removed invalid `?` keyboard shortcut tip, replaced with `Shift+Tab` for cycling thinking level
18
+
3
19
  ## [0.2.5] - 2026-01-16
4
20
 
5
21
  ### Added
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
- setupWelcomeOverlay(ctx);
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
- // setFooter(undefined) internally calls the old footer's dispose()
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 = allSegments.length - 1; numSegments >= 1; 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
- // Re-render when thinking level or other settings change
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 = allSegments.length - 1; numSegments >= 1; 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 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
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
- // Discover loaded counts and recent sessions
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 before showing overlay
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
- // Update countdown every second
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, // TUI sets this to true when focused
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
- // Dismissed, ignore
492
- });
493
- }, 100); // Small delay to let init complete
543
+ ).catch(() => {});
544
+ }, 100);
494
545
  }
495
546
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-powerline-footer",
3
- "version": "0.2.5",
3
+ "version": "0.2.8",
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 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
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,