@groeponline/pi-wishcraft 0.17.3

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 (106) hide show
  1. package/AGENTS.md +68 -0
  2. package/CHANGELOG.md +724 -0
  3. package/CONTRIBUTING.md +37 -0
  4. package/README.md +648 -0
  5. package/RELEASE.md +117 -0
  6. package/ROADMAP.md +52 -0
  7. package/bash-mode/completion-providers.ts +269 -0
  8. package/bash-mode/completion.ts +416 -0
  9. package/bash-mode/editor-ghost.ts +40 -0
  10. package/bash-mode/editor-input.ts +80 -0
  11. package/bash-mode/editor.ts +437 -0
  12. package/bash-mode/history.ts +263 -0
  13. package/bash-mode/shell-session.ts +286 -0
  14. package/bash-mode/transcript.ts +108 -0
  15. package/bash-mode/types.ts +80 -0
  16. package/index.ts +6 -0
  17. package/package.json +55 -0
  18. package/queue/store.ts +443 -0
  19. package/queue/types.ts +54 -0
  20. package/src/config/custom-items.ts +182 -0
  21. package/src/config/extension-statuses.ts +51 -0
  22. package/src/config/layout.ts +60 -0
  23. package/src/config/parse.ts +127 -0
  24. package/src/config/powerline-config.ts +18 -0
  25. package/src/config/presets.ts +245 -0
  26. package/src/config/primitives.ts +117 -0
  27. package/src/config/segment-ids.ts +114 -0
  28. package/src/config/segment-options.ts +128 -0
  29. package/src/config/settings-patch.ts +26 -0
  30. package/src/config/types.ts +277 -0
  31. package/src/core/frontmatter.ts +40 -0
  32. package/src/editor/autocomplete-chain.ts +41 -0
  33. package/src/extension/activate.ts +28 -0
  34. package/src/extension/bash-mode-actions.ts +104 -0
  35. package/src/extension/commands.ts +268 -0
  36. package/src/extension/constants.ts +46 -0
  37. package/src/extension/custom-editor.ts +406 -0
  38. package/src/extension/git-invalidation.ts +40 -0
  39. package/src/extension/layout.ts +160 -0
  40. package/src/extension/menu-views.ts +393 -0
  41. package/src/extension/powerline-widgets.ts +95 -0
  42. package/src/extension/prompt-history.ts +219 -0
  43. package/src/extension/queue-commands.ts +245 -0
  44. package/src/extension/queue-context.ts +12 -0
  45. package/src/extension/queue-integration.ts +434 -0
  46. package/src/extension/segment-context.ts +212 -0
  47. package/src/extension/session-lifecycle.ts +373 -0
  48. package/src/extension/settings-io.ts +202 -0
  49. package/src/extension/shortcuts-config.ts +357 -0
  50. package/src/extension/shortcuts-router.ts +383 -0
  51. package/src/extension/skills/inline-invocation.ts +174 -0
  52. package/src/extension/skills/ook.md +6 -0
  53. package/src/extension/skills/test.md +6 -0
  54. package/src/extension/stale-context.ts +10 -0
  55. package/src/extension/stash-history.ts +103 -0
  56. package/src/extension/state.ts +159 -0
  57. package/src/extension/status-line-renderers.ts +222 -0
  58. package/src/extension/types.ts +97 -0
  59. package/src/extension/vibe-command.ts +160 -0
  60. package/src/extension/welcome-control.ts +27 -0
  61. package/src/extension/welcome-integration.ts +153 -0
  62. package/src/git/status.ts +332 -0
  63. package/src/paths/agent-dirs.ts +67 -0
  64. package/src/render/timer.ts +46 -0
  65. package/src/segments/core.ts +256 -0
  66. package/src/segments/custom.ts +114 -0
  67. package/src/segments/index.ts +3 -0
  68. package/src/segments/registry.ts +87 -0
  69. package/src/segments/shared.ts +36 -0
  70. package/src/segments/system.ts +235 -0
  71. package/src/segments/usage.ts +178 -0
  72. package/src/shell/cd-command.ts +190 -0
  73. package/src/shortcuts/matching.ts +61 -0
  74. package/src/theme/colors.ts +60 -0
  75. package/src/theme/icons.ts +175 -0
  76. package/src/theme/separators.ts +41 -0
  77. package/src/theme/theme.ts +211 -0
  78. package/src/tools/graph.ts +75 -0
  79. package/src/tools/patch.ts +179 -0
  80. package/src/tools/ripgrep.ts +104 -0
  81. package/src/usage/context.ts +97 -0
  82. package/src/usage/ledger.ts +293 -0
  83. package/src/usage/rates.ts +155 -0
  84. package/src/welcome/auto-dismiss.ts +43 -0
  85. package/src/welcome/banner.ts +68 -0
  86. package/src/welcome/discover.ts +234 -0
  87. package/src/welcome/format.ts +18 -0
  88. package/src/welcome/index.ts +5 -0
  89. package/src/welcome/layout.ts +36 -0
  90. package/src/welcome/overlay.ts +80 -0
  91. package/src/welcome/renderer.ts +157 -0
  92. package/src/welcome/sessions.ts +107 -0
  93. package/src/welcome/types.ts +41 -0
  94. package/src/welcome/widgets/graph-widget.ts +25 -0
  95. package/src/welcome/widgets/index.ts +20 -0
  96. package/src/welcome/widgets/queue-widget.ts +26 -0
  97. package/src/welcome/widgets/sessions-widget.ts +23 -0
  98. package/src/welcome/widgets/shortcuts-widget.ts +17 -0
  99. package/src/welcome/widgets/system-widget.ts +29 -0
  100. package/src/working-vibes/generate.ts +144 -0
  101. package/src/working-vibes/index.ts +24 -0
  102. package/src/working-vibes/manager.ts +198 -0
  103. package/src/working-vibes/provider.ts +163 -0
  104. package/src/working-vibes/storage.ts +357 -0
  105. package/theme.example.json +24 -0
  106. package/tsconfig.json +13 -0
@@ -0,0 +1,155 @@
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+ import { getAgentPath } from "../paths/agent-dirs.ts";
4
+
5
+ export const SUPPORTED_COST_CURRENCIES = [
6
+ "USD",
7
+ "CNY",
8
+ "EUR",
9
+ "GBP",
10
+ "JPY",
11
+ "CAD",
12
+ "AUD",
13
+ "CHF",
14
+ "INR",
15
+ "KRW",
16
+ ] as const;
17
+
18
+ export type CostCurrencyCode = (typeof SUPPORTED_COST_CURRENCIES)[number];
19
+
20
+ const SYMBOLS: Record<CostCurrencyCode, string> = {
21
+ USD: "$",
22
+ CNY: "¥",
23
+ EUR: "€",
24
+ GBP: "£",
25
+ JPY: "¥",
26
+ CAD: "CA$",
27
+ AUD: "A$",
28
+ CHF: "CHF ",
29
+ INR: "₹",
30
+ KRW: "₩",
31
+ };
32
+
33
+ const TTL_MS = 24 * 60 * 60 * 1000;
34
+ const ENDPOINT = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.min.json";
35
+ const CACHE_FILE = getAgentPath("powerline-footer", "currency-rates.json");
36
+
37
+ export type CurrencyDisplayMode = "symbol" | "code" | "both";
38
+
39
+ export function currencySymbol(currency: CostCurrencyCode): string {
40
+ return SYMBOLS[currency];
41
+ }
42
+
43
+ export function normalizeCostCurrency(value: unknown): CostCurrencyCode | undefined {
44
+ if (typeof value !== "string") return undefined;
45
+ const upper = value.trim().toUpperCase();
46
+ return (SUPPORTED_COST_CURRENCIES as readonly string[]).includes(upper)
47
+ ? (upper as CostCurrencyCode)
48
+ : undefined;
49
+ }
50
+
51
+ interface CacheData {
52
+ timestamp: number;
53
+ rates: Partial<Record<CostCurrencyCode, number>>;
54
+ }
55
+
56
+ let activeCache: CacheData | null = null;
57
+ let updatePromise: Promise<void> | null = null;
58
+
59
+ async function loadFromDisk(): Promise<CacheData | null> {
60
+ try {
61
+ const raw = await readFile(CACHE_FILE, "utf8");
62
+ const parsed = JSON.parse(raw);
63
+ if (typeof parsed === "object" && parsed && typeof parsed.timestamp === "number" && typeof parsed.rates === "object" && parsed.rates) {
64
+ return parsed as CacheData;
65
+ }
66
+ } catch {
67
+ // Ignore read errors
68
+ }
69
+ return null;
70
+ }
71
+
72
+ async function saveToDisk(data: CacheData): Promise<void> {
73
+ try {
74
+ await mkdir(dirname(CACHE_FILE), { recursive: true });
75
+ await writeFile(CACHE_FILE, JSON.stringify(data));
76
+ } catch {
77
+ // Ignore write errors
78
+ }
79
+ }
80
+
81
+ async function fetchRatesFromNetwork(): Promise<CacheData> {
82
+ const controller = new AbortController();
83
+ const id = setTimeout(() => controller.abort(), 10000); // 10s timeout
84
+ try {
85
+ const res = await fetch(ENDPOINT, { signal: controller.signal });
86
+ if (!res.ok) throw new Error("Fetch failed");
87
+ const body = await res.json();
88
+ if (!body || typeof body !== "object" || !body.usd) throw new Error("Invalid response format");
89
+ const rates: Partial<Record<CostCurrencyCode, number>> = { USD: 1 };
90
+ for (const code of SUPPORTED_COST_CURRENCIES) {
91
+ if (code === "USD") continue;
92
+ const rate = body.usd[code.toLowerCase()];
93
+ if (typeof rate === "number" && Number.isFinite(rate) && rate > 0) {
94
+ rates[code] = rate;
95
+ }
96
+ }
97
+ return { timestamp: Date.now(), rates };
98
+ } finally {
99
+ clearTimeout(id);
100
+ }
101
+ }
102
+
103
+ function triggerRefresh(now: number): void {
104
+ if (updatePromise) return;
105
+ if (activeCache && now - activeCache.timestamp < TTL_MS) return;
106
+
107
+ updatePromise = (async () => {
108
+ if (!activeCache) activeCache = await loadFromDisk();
109
+ if (activeCache && Date.now() - activeCache.timestamp < TTL_MS) return;
110
+
111
+ try {
112
+ const latest = await fetchRatesFromNetwork();
113
+ activeCache = latest;
114
+ void saveToDisk(latest);
115
+ } catch {
116
+ if (!activeCache) activeCache = await loadFromDisk();
117
+ }
118
+ })().finally(() => {
119
+ updatePromise = null;
120
+ });
121
+ }
122
+
123
+ function getConversionRate(currency: CostCurrencyCode): number | null {
124
+ if (currency === "USD") return 1;
125
+ const r = activeCache?.rates[currency];
126
+ triggerRefresh(Date.now());
127
+ return typeof r === "number" && Number.isFinite(r) && r > 0 ? r : null;
128
+ }
129
+
130
+ export function convertCost(amountUsd: number, currency: CostCurrencyCode): number | null {
131
+ const rate = getConversionRate(currency);
132
+ if (!rate) return null;
133
+ return amountUsd * rate;
134
+ }
135
+
136
+ export function formatDisplayCost(amountUsd: number, currency: CostCurrencyCode = "USD"): string | null {
137
+ const converted = convertCost(amountUsd, currency);
138
+ if (converted === null) return `-- ${currency}`;
139
+ const decimals = (currency === "JPY" || currency === "KRW") ? 0 : 2;
140
+ return `${currencySymbol(currency)}${converted.toFixed(decimals)}`;
141
+ }
142
+
143
+ export function formatUsdCost(amountUsd: number, currency: CostCurrencyCode = "USD"): string | null {
144
+ return formatDisplayCost(amountUsd, currency);
145
+ }
146
+
147
+ export function __setCurrencyRatesForTest(rates: Partial<Record<CostCurrencyCode, number>>, timestamp = Date.now()): void {
148
+ activeCache = { timestamp, rates: { USD: 1, ...rates } };
149
+ updatePromise = null;
150
+ }
151
+
152
+ export function __resetCurrencyRatesForTest(): void {
153
+ activeCache = null;
154
+ updatePromise = null;
155
+ }
@@ -0,0 +1,43 @@
1
+ export interface WelcomeDismissScheduler<Context> {
2
+ schedule(ctx: Context): void;
3
+ cancel(): void;
4
+ }
5
+
6
+ interface WelcomeDismissSchedulerOptions<Context> {
7
+ dismiss(ctx: Context): void;
8
+ getGeneration(): number;
9
+ isEnabled(): boolean;
10
+ }
11
+
12
+ export function createWelcomeDismissScheduler<Context>(
13
+ options: WelcomeDismissSchedulerOptions<Context>,
14
+ ): WelcomeDismissScheduler<Context> {
15
+ let activeTimer: ReturnType<typeof setTimeout> | null = null;
16
+
17
+ return {
18
+ schedule(ctx) {
19
+ if (activeTimer !== null) {
20
+ return;
21
+ }
22
+
23
+ const capturedGeneration = options.getGeneration();
24
+
25
+ activeTimer = setTimeout(() => {
26
+ activeTimer = null;
27
+
28
+ if (!options.isEnabled() || capturedGeneration !== options.getGeneration()) {
29
+ return;
30
+ }
31
+
32
+ options.dismiss(ctx);
33
+ }, 0);
34
+ },
35
+ cancel() {
36
+ if (activeTimer === null) {
37
+ return;
38
+ }
39
+ clearTimeout(activeTimer);
40
+ activeTimer = null;
41
+ },
42
+ };
43
+ }
@@ -0,0 +1,68 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { dim, renderWelcomeBox } from "./renderer.ts";
3
+ import type { WelcomeData } from "./types.ts";
4
+ import type { LoadedCounts, RecentSession } from "./types.ts";
5
+
6
+ /**
7
+ * Welcome header - same layout as overlay but persistent (no countdown).
8
+ * Used when quietStartup: true.
9
+ */
10
+ export class WelcomeHeader implements Component {
11
+ private data: WelcomeData;
12
+
13
+ constructor(
14
+ modelName: string,
15
+ providerName: string,
16
+ recentSessions: RecentSession[] = [],
17
+ loadedCounts: LoadedCounts = {
18
+ contextFiles: 0,
19
+ extensions: 0,
20
+ skills: 0,
21
+ promptTemplates: 0,
22
+ },
23
+ initialContextTokens: number | null = null,
24
+ queueCount?: number,
25
+ hasStash?: boolean,
26
+ ) {
27
+ this.data = {
28
+ modelName,
29
+ providerName,
30
+ recentSessions,
31
+ loadedCounts,
32
+ initialContextTokens,
33
+ queueCount,
34
+ hasStash,
35
+ };
36
+ }
37
+
38
+ invalidate(): void {}
39
+
40
+ render(termWidth: number): string[] {
41
+ // Minimum width for two-column layout (must match renderWelcomeBox)
42
+ const minLayoutWidth = 44;
43
+ if (termWidth < minLayoutWidth) {
44
+ return [];
45
+ }
46
+
47
+ const minWidth = 76;
48
+ const maxWidth = 96;
49
+ // Clamp to termWidth to prevent crash on narrow terminals
50
+ const boxWidth = Math.min(
51
+ termWidth,
52
+ Math.max(minWidth, Math.min(termWidth - 2, maxWidth)),
53
+ );
54
+ const hChar = "─";
55
+
56
+ // Bottom line with column separator (leftCol=26, rightCol=boxWidth-29)
57
+ const leftCol = 26;
58
+ const rightCol = Math.max(1, boxWidth - leftCol - 3);
59
+ const bottomLine =
60
+ dim(hChar.repeat(leftCol)) + dim("┴") + dim(hChar.repeat(rightCol));
61
+
62
+ const lines = renderWelcomeBox(this.data, termWidth, bottomLine);
63
+ if (lines.length > 0) {
64
+ lines.push(""); // Add empty line for spacing only if we rendered content
65
+ }
66
+ return lines;
67
+ }
68
+ }
@@ -0,0 +1,234 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join, basename } from "node:path";
3
+ import { getAgentPath, getHomeDir } from "../paths/agent-dirs.ts";
4
+ import type { LoadedCounts } from "./types.ts";
5
+
6
+ const loggedDiscoveryErrors = new Set<string>();
7
+
8
+ export function logDiscoveryError(scope: string, error: unknown): void {
9
+ if (
10
+ typeof error === "object" &&
11
+ error !== null &&
12
+ "code" in error &&
13
+ (error as { code?: unknown }).code === "ENOENT"
14
+ ) {
15
+ return;
16
+ }
17
+
18
+ const message = error instanceof Error ? error.message : String(error);
19
+ const key = `${scope}:${message}`;
20
+ if (loggedDiscoveryErrors.has(key)) {
21
+ return;
22
+ }
23
+
24
+ loggedDiscoveryErrors.add(key);
25
+ if (loggedDiscoveryErrors.size > 500) {
26
+ loggedDiscoveryErrors.clear();
27
+ }
28
+
29
+ console.debug(`[powerline-welcome] ${scope}:`, error);
30
+ }
31
+
32
+ function scanContextFiles(homeDir: string, cwd: string): number {
33
+ let count = 0;
34
+ const agentsMdPaths = [
35
+ getAgentPath("AGENTS.md"),
36
+ join(homeDir, ".claude", "AGENTS.md"),
37
+ join(cwd, "AGENTS.md"),
38
+ join(cwd, ".pi", "AGENTS.md"),
39
+ join(cwd, ".claude", "AGENTS.md"),
40
+ ];
41
+
42
+ for (const path of agentsMdPaths) {
43
+ if (existsSync(path)) count++;
44
+ }
45
+ return count;
46
+ }
47
+
48
+ function scanExtensions(cwd: string): number {
49
+ let count = 0;
50
+ const countedExtensions = new Set<string>();
51
+ const settingsPaths = [
52
+ getAgentPath("settings.json"),
53
+ join(cwd, ".pi", "settings.json"),
54
+ ];
55
+
56
+ for (const settingsPath of settingsPaths) {
57
+ if (!existsSync(settingsPath)) continue;
58
+
59
+ try {
60
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
61
+ let packages: unknown = null;
62
+ if (typeof settings === "object" && settings !== null && !Array.isArray(settings)) {
63
+ packages = "packages" in settings ? (settings as { packages: unknown }).packages : null;
64
+ }
65
+
66
+ if (Array.isArray(packages)) {
67
+ for (const pkg of packages) {
68
+ let source: unknown = null;
69
+ let extensionsFilter: unknown = null;
70
+
71
+ if (typeof pkg === "string") {
72
+ source = pkg;
73
+ } else if (typeof pkg === "object" && pkg !== null && !Array.isArray(pkg)) {
74
+ source = "source" in pkg ? (pkg as { source: unknown }).source : null;
75
+ extensionsFilter = "extensions" in pkg ? (pkg as { extensions: unknown }).extensions : null;
76
+ }
77
+
78
+ if (typeof source !== "string") continue;
79
+
80
+ const normalizedSource = source.trim();
81
+ if (!normalizedSource.startsWith("npm:")) continue;
82
+ if (Array.isArray(extensionsFilter) && extensionsFilter.length === 0) continue;
83
+
84
+ const body = normalizedSource.slice(4);
85
+ const versionIndex = body.lastIndexOf("@");
86
+ const name = versionIndex > 0 ? body.slice(0, versionIndex) : body;
87
+
88
+ if (!name || countedExtensions.has(name)) continue;
89
+
90
+ countedExtensions.add(name);
91
+ count++;
92
+ }
93
+ }
94
+ } catch (error) {
95
+ logDiscoveryError(`Failed to read settings at ${settingsPath}`, error);
96
+ }
97
+ }
98
+
99
+ const extensionDirs = [
100
+ getAgentPath("extensions"),
101
+ join(cwd, "extensions"),
102
+ join(cwd, ".pi", "extensions"),
103
+ ];
104
+
105
+ for (const dir of extensionDirs) {
106
+ if (!existsSync(dir)) continue;
107
+
108
+ try {
109
+ const entries = readdirSync(dir);
110
+ for (const entry of entries) {
111
+ const entryPath = join(dir, entry);
112
+ try {
113
+ const stats = statSync(entryPath);
114
+ if (stats.isDirectory()) {
115
+ if (
116
+ existsSync(join(entryPath, "index.ts")) ||
117
+ existsSync(join(entryPath, "index.js")) ||
118
+ existsSync(join(entryPath, "package.json"))
119
+ ) {
120
+ if (!countedExtensions.has(entry)) {
121
+ countedExtensions.add(entry);
122
+ count++;
123
+ }
124
+ }
125
+ } else if ((entry.endsWith(".ts") || entry.endsWith(".js")) && !entry.startsWith(".")) {
126
+ const ext = entry.endsWith(".ts") ? ".ts" : ".js";
127
+ const name = basename(entry, ext);
128
+ if (!countedExtensions.has(name)) {
129
+ countedExtensions.add(name);
130
+ count++;
131
+ }
132
+ }
133
+ } catch (error) {
134
+ logDiscoveryError(`Failed to inspect extension entry ${entryPath}`, error);
135
+ }
136
+ }
137
+ } catch (error) {
138
+ logDiscoveryError(`Failed to scan extensions dir ${dir}`, error);
139
+ }
140
+ }
141
+
142
+ return count;
143
+ }
144
+
145
+ function scanSkills(cwd: string): number {
146
+ let count = 0;
147
+ const countedSkills = new Set<string>();
148
+ const skillDirs = [
149
+ getAgentPath("skills"),
150
+ join(cwd, ".pi", "skills"),
151
+ join(cwd, "skills"),
152
+ ];
153
+
154
+ for (const dir of skillDirs) {
155
+ if (!existsSync(dir)) continue;
156
+
157
+ try {
158
+ const entries = readdirSync(dir);
159
+ for (const entry of entries) {
160
+ const entryPath = join(dir, entry);
161
+ try {
162
+ if (statSync(entryPath).isDirectory() && existsSync(join(entryPath, "SKILL.md"))) {
163
+ if (!countedSkills.has(entry)) {
164
+ countedSkills.add(entry);
165
+ count++;
166
+ }
167
+ }
168
+ } catch (error) {
169
+ logDiscoveryError(`Failed to inspect skill entry ${entryPath}`, error);
170
+ }
171
+ }
172
+ } catch (error) {
173
+ logDiscoveryError(`Failed to scan skills dir ${dir}`, error);
174
+ }
175
+ }
176
+
177
+ return count;
178
+ }
179
+
180
+ function scanPromptTemplates(homeDir: string, cwd: string): number {
181
+ const countedTemplates = new Set<string>();
182
+ let count = 0;
183
+
184
+ function countTemplatesInDir(dir: string) {
185
+ if (!existsSync(dir)) return;
186
+ try {
187
+ const entries = readdirSync(dir);
188
+ for (const entry of entries) {
189
+ const entryPath = join(dir, entry);
190
+ try {
191
+ const stats = statSync(entryPath);
192
+ if (stats.isDirectory()) {
193
+ countTemplatesInDir(entryPath);
194
+ } else if (entry.endsWith(".md")) {
195
+ const name = basename(entry, ".md");
196
+ if (!countedTemplates.has(name)) {
197
+ countedTemplates.add(name);
198
+ count++;
199
+ }
200
+ }
201
+ } catch (error) {
202
+ logDiscoveryError(`Failed to inspect prompt template entry ${entryPath}`, error);
203
+ }
204
+ }
205
+ } catch (error) {
206
+ logDiscoveryError(`Failed to scan prompt template dir ${dir}`, error);
207
+ }
208
+ }
209
+
210
+ const templateDirs = [
211
+ getAgentPath("commands"),
212
+ join(homeDir, ".claude", "commands"),
213
+ join(cwd, ".pi", "commands"),
214
+ join(cwd, ".claude", "commands"),
215
+ ];
216
+
217
+ for (const dir of templateDirs) {
218
+ countTemplatesInDir(dir);
219
+ }
220
+
221
+ return count;
222
+ }
223
+
224
+ export function discoverLoadedCounts(): LoadedCounts {
225
+ const homeDir = getHomeDir();
226
+ const cwd = process.cwd();
227
+
228
+ const contextFiles = scanContextFiles(homeDir, cwd);
229
+ const extensions = scanExtensions(cwd);
230
+ const skills = scanSkills(cwd);
231
+ const promptTemplates = scanPromptTemplates(homeDir, cwd);
232
+
233
+ return { contextFiles, extensions, skills, promptTemplates };
234
+ }
@@ -0,0 +1,18 @@
1
+ export function formatTokens(tokens: number): string {
2
+ if (tokens < 1000) return tokens.toString();
3
+ if (tokens < 10000) return `${(tokens / 1000).toFixed(1)}k`;
4
+ if (tokens < 1000000) return `${Math.round(tokens / 1000)}k`;
5
+ return `${(tokens / 1000000).toFixed(tokens < 10000000 ? 1 : 0)}M`;
6
+ }
7
+
8
+ export function formatTimeAgo(ms: number): string {
9
+ const seconds = Math.floor(ms / 1000);
10
+ const minutes = Math.floor(seconds / 60);
11
+ const hours = Math.floor(minutes / 60);
12
+ const days = Math.floor(hours / 24);
13
+
14
+ if (days > 0) return `${days}d ago`;
15
+ if (hours > 0) return `${hours}h ago`;
16
+ if (minutes > 0) return `${minutes}m ago`;
17
+ return "just now";
18
+ }
@@ -0,0 +1,5 @@
1
+ export type { LoadedCounts, RecentSession } from "./types.ts";
2
+ export { WelcomeComponent } from "./overlay.ts";
3
+ export { WelcomeHeader } from "./banner.ts";
4
+ export { discoverLoadedCounts } from "./discover.ts";
5
+ export { getRecentSessions } from "./sessions.ts";
@@ -0,0 +1,36 @@
1
+ import { visibleWidth, truncateToWidth } from "@earendil-works/pi-tui";
2
+
3
+ export function fitToWidth(str: string, width: number): string {
4
+ const visLen = visibleWidth(str);
5
+ if (visLen > width) return truncateToWidth(str, width, "…");
6
+ return str + " ".repeat(width - visLen);
7
+ }
8
+
9
+ export function centerText(text: string, width: number): string {
10
+ const visLen = visibleWidth(text);
11
+ if (visLen > width) return truncateToWidth(text, width, "…");
12
+ if (visLen === width) return text;
13
+ const leftPad = Math.floor((width - visLen) / 2);
14
+ const rightPad = width - visLen - leftPad;
15
+ return " ".repeat(leftPad) + text + " ".repeat(rightPad);
16
+ }
17
+
18
+ export function getBoxLayout(termWidth: number) {
19
+ const minLayoutWidth = 44;
20
+
21
+ if (termWidth < minLayoutWidth) {
22
+ return null;
23
+ }
24
+
25
+ const minWidth = 76;
26
+ const maxWidth = 96;
27
+ const boxWidth = Math.min(
28
+ termWidth,
29
+ Math.max(minWidth, Math.min(termWidth - 2, maxWidth))
30
+ );
31
+
32
+ const leftCol = 26;
33
+ const rightCol = Math.max(1, boxWidth - leftCol - 3);
34
+
35
+ return { boxWidth, leftCol, rightCol };
36
+ }
@@ -0,0 +1,80 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
+ import { dim, renderWelcomeBox } from "./renderer.ts";
4
+ import type { WelcomeData } from "./types.ts";
5
+ import type { LoadedCounts, RecentSession } from "./types.ts";
6
+
7
+ // ═══════════════════════════════════════════════════════════════════════════
8
+ // Welcome Components
9
+ // ═══════════════════════════════════════════════════════════════════════════
10
+
11
+ /**
12
+ * Welcome overlay component for pi agent.
13
+ * Displays a branded splash screen with logo, tips, and loaded counts.
14
+ */
15
+ export class WelcomeComponent implements Component {
16
+ private data: WelcomeData;
17
+ private countdown: number = 30;
18
+
19
+ constructor(
20
+ modelName: string,
21
+ providerName: string,
22
+ recentSessions: RecentSession[] = [],
23
+ loadedCounts: LoadedCounts = {
24
+ contextFiles: 0,
25
+ extensions: 0,
26
+ skills: 0,
27
+ promptTemplates: 0,
28
+ },
29
+ initialContextTokens: number | null = null,
30
+ queueCount?: number,
31
+ hasStash?: boolean,
32
+ ) {
33
+ this.data = {
34
+ modelName,
35
+ providerName,
36
+ recentSessions,
37
+ loadedCounts,
38
+ initialContextTokens,
39
+ queueCount,
40
+ hasStash,
41
+ };
42
+ }
43
+
44
+ setCountdown(seconds: number): void {
45
+ this.countdown = seconds;
46
+ }
47
+
48
+ invalidate(): void {}
49
+
50
+ render(termWidth: number): string[] {
51
+ // Minimum width for two-column layout (must match renderWelcomeBox)
52
+ const minLayoutWidth = 44;
53
+ if (termWidth < minLayoutWidth) {
54
+ return [];
55
+ }
56
+
57
+ const minWidth = 76;
58
+ const maxWidth = 96;
59
+ // Clamp to termWidth to prevent crash on narrow terminals
60
+ const boxWidth = Math.min(
61
+ termWidth,
62
+ Math.max(minWidth, Math.min(termWidth - 2, maxWidth)),
63
+ );
64
+
65
+ // Bottom line with countdown
66
+ const countdownText = ` Press any key to continue (${this.countdown}s) `;
67
+ const countdownStyled = dim(countdownText);
68
+ const bottomContentWidth = boxWidth - 2;
69
+ const countdownVisLen = visibleWidth(countdownText);
70
+ const leftPad = Math.floor((bottomContentWidth - countdownVisLen) / 2);
71
+ const rightPad = bottomContentWidth - countdownVisLen - leftPad;
72
+ const hChar = "─";
73
+ const bottomLine =
74
+ dim(hChar.repeat(Math.max(0, leftPad))) +
75
+ countdownStyled +
76
+ dim(hChar.repeat(Math.max(0, rightPad)));
77
+
78
+ return renderWelcomeBox(this.data, termWidth, bottomLine);
79
+ }
80
+ }