@selesai/code 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +4 -2
- package/dist/extensions/agent-browser.test.ts +195 -0
- package/dist/extensions/agent-browser.ts +158 -0
- package/dist/extensions/inline-skills.test.ts +14 -0
- package/dist/extensions/inline-skills.ts +14 -7
- package/dist/extensions/package.json +2 -1
- package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +27 -0
- package/dist/extensions/pi-powerline-footer/CHANGELOG.md +156 -0
- package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +16 -0
- package/dist/extensions/pi-powerline-footer/README.md +192 -44
- package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +19 -4
- package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +80 -25
- package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +81 -29
- package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +1 -1
- package/dist/extensions/pi-powerline-footer/cd-command.ts +190 -0
- package/dist/extensions/pi-powerline-footer/colors.ts +2 -1
- package/dist/extensions/pi-powerline-footer/context-usage.ts +121 -6
- package/dist/extensions/pi-powerline-footer/currency-rates.ts +142 -0
- package/dist/extensions/pi-powerline-footer/editor-composition.ts +32 -0
- package/dist/extensions/pi-powerline-footer/git-status.ts +108 -6
- package/dist/extensions/pi-powerline-footer/guide.ts +1 -1
- package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
- package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
- package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
- package/dist/extensions/pi-powerline-footer/package.json +10 -6
- package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
- package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
- package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
- package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
- package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
- package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
- package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
- package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
- package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
- package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
- package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
- package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
- package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
- package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
- package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
- package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
- package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
- package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +11 -6
- package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
- package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
- package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
- package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
- package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
- package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
- package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
- package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
- package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
- package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
- package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
- package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
- package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
- package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
- package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
- package/dist/extensions/pi-powerline-footer/types.ts +74 -29
- package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
- package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
- package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
- package/dist/extensions/pi-subagents/package-lock.json +2 -2
- package/dist/extensions/pi-subagents/package.json +1 -1
- package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
- package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
- package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
- package/dist/extensions/ponytail/index.js +1 -31
- package/dist/extensions/ponytail/test/extension.test.js +5 -22
- package/dist/extensions/rtk.test.ts +27 -12
- package/dist/extensions/rtk.ts +41 -35
- package/package.json +1 -1
|
@@ -1,11 +1,123 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { estimateTokens, sessionEntryToContextMessages, type SessionEntry } from "@selesai/code";
|
|
2
|
+
|
|
3
|
+
export interface CoreContextUsage {
|
|
4
|
+
contextTokens: number | null;
|
|
3
5
|
contextWindow: number;
|
|
4
|
-
contextPercent: number;
|
|
6
|
+
contextPercent: number | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface DisplayContextUsageInput {
|
|
10
|
+
coreContextUsage: CoreContextUsage | null;
|
|
11
|
+
unknownCoreFallback: CoreContextUsage | null;
|
|
12
|
+
fallbackContextTokens: number;
|
|
13
|
+
fallbackContextWindow: number;
|
|
5
14
|
}
|
|
6
15
|
|
|
7
16
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
8
|
-
return typeof value === "object" && value !== null;
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface ContextUsageSource {
|
|
21
|
+
sessionManager: {
|
|
22
|
+
getLeafId(): string | null;
|
|
23
|
+
};
|
|
24
|
+
getContextUsage(): unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface UnknownContextEstimateSource {
|
|
28
|
+
sessionManager: {
|
|
29
|
+
buildContextEntries(): SessionEntry[];
|
|
30
|
+
};
|
|
31
|
+
getContextUsage(): unknown;
|
|
32
|
+
getSystemPrompt(): string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function isContextUsageSource(value: unknown): value is ContextUsageSource {
|
|
36
|
+
if (!isRecord(value) || typeof value.getContextUsage !== "function" || !isRecord(value.sessionManager)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
return typeof value.sessionManager.getLeafId === "function";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isUnknownContextEstimateSource(value: unknown): value is UnknownContextEstimateSource {
|
|
43
|
+
return isRecord(value)
|
|
44
|
+
&& typeof value.getContextUsage === "function"
|
|
45
|
+
&& typeof value.getSystemPrompt === "function"
|
|
46
|
+
&& isRecord(value.sessionManager)
|
|
47
|
+
&& typeof value.sessionManager.buildContextEntries === "function";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class CoreContextUsageCache {
|
|
51
|
+
private sessionManager: ContextUsageSource["sessionManager"] | null = null;
|
|
52
|
+
private leafId: string | null = null;
|
|
53
|
+
private usage: CoreContextUsage | null = null;
|
|
54
|
+
|
|
55
|
+
get(ctx: unknown): CoreContextUsage | null {
|
|
56
|
+
if (!isContextUsageSource(ctx)) return readCoreContextUsage(ctx);
|
|
57
|
+
|
|
58
|
+
const sessionManager = ctx.sessionManager;
|
|
59
|
+
const leafId = sessionManager.getLeafId();
|
|
60
|
+
// A reset cache holds sessionManager === null, which never matches a live one.
|
|
61
|
+
if (this.sessionManager !== sessionManager || this.leafId !== leafId) {
|
|
62
|
+
this.sessionManager = sessionManager;
|
|
63
|
+
this.leafId = leafId;
|
|
64
|
+
this.usage = readCoreContextUsage(ctx);
|
|
65
|
+
}
|
|
66
|
+
return this.usage;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
reset(): void {
|
|
70
|
+
this.sessionManager = null;
|
|
71
|
+
this.leafId = null;
|
|
72
|
+
this.usage = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function estimateInitialContextTokens(ctx: unknown): number | null {
|
|
77
|
+
if (!isRecord(ctx) || typeof ctx.getSystemPrompt !== "function") {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const prompt = ctx.getSystemPrompt();
|
|
82
|
+
if (typeof prompt !== "string" || !prompt.trim()) {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Math.ceil(prompt.length / 4);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveDisplayContextUsage({
|
|
90
|
+
coreContextUsage,
|
|
91
|
+
unknownCoreFallback,
|
|
92
|
+
fallbackContextTokens,
|
|
93
|
+
fallbackContextWindow,
|
|
94
|
+
}: DisplayContextUsageInput): CoreContextUsage {
|
|
95
|
+
if (coreContextUsage?.contextTokens === null && unknownCoreFallback) return unknownCoreFallback;
|
|
96
|
+
if (coreContextUsage) return coreContextUsage;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
contextTokens: fallbackContextTokens,
|
|
100
|
+
contextWindow: fallbackContextWindow,
|
|
101
|
+
contextPercent: fallbackContextWindow > 0 ? (fallbackContextTokens / fallbackContextWindow) * 100 : 0,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function estimateUnknownContextUsage(ctx: unknown): CoreContextUsage | null {
|
|
106
|
+
if (!isUnknownContextEstimateSource(ctx)) return null;
|
|
107
|
+
|
|
108
|
+
const coreContextUsage = readCoreContextUsage(ctx);
|
|
109
|
+
if (!coreContextUsage || coreContextUsage.contextTokens !== null) return null;
|
|
110
|
+
|
|
111
|
+
const messageTokens = ctx.sessionManager.buildContextEntries()
|
|
112
|
+
.flatMap(sessionEntryToContextMessages)
|
|
113
|
+
.reduce((total, message) => total + estimateTokens(message), 0);
|
|
114
|
+
const contextTokens = (estimateInitialContextTokens(ctx) ?? 0) + messageTokens;
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
contextTokens,
|
|
118
|
+
contextWindow: coreContextUsage.contextWindow,
|
|
119
|
+
contextPercent: (contextTokens / coreContextUsage.contextWindow) * 100,
|
|
120
|
+
};
|
|
9
121
|
}
|
|
10
122
|
|
|
11
123
|
export function readCoreContextUsage(ctx: unknown): CoreContextUsage | null {
|
|
@@ -21,8 +133,7 @@ export function readCoreContextUsage(ctx: unknown): CoreContextUsage | null {
|
|
|
21
133
|
const tokens = usage.tokens;
|
|
22
134
|
const contextWindow = usage.contextWindow;
|
|
23
135
|
if (
|
|
24
|
-
typeof tokens
|
|
25
|
-
|| !Number.isFinite(tokens)
|
|
136
|
+
!(tokens === null || (typeof tokens === "number" && Number.isFinite(tokens)))
|
|
26
137
|
|| typeof contextWindow !== "number"
|
|
27
138
|
|| !Number.isFinite(contextWindow)
|
|
28
139
|
|| contextWindow <= 0
|
|
@@ -30,6 +141,10 @@ export function readCoreContextUsage(ctx: unknown): CoreContextUsage | null {
|
|
|
30
141
|
return null;
|
|
31
142
|
}
|
|
32
143
|
|
|
144
|
+
if (tokens === null) {
|
|
145
|
+
return { contextTokens: null, contextWindow, contextPercent: null };
|
|
146
|
+
}
|
|
147
|
+
|
|
33
148
|
const percent = usage.percent;
|
|
34
149
|
return {
|
|
35
150
|
contextTokens: tokens,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { getAgentPath } from "./paths.ts";
|
|
4
|
+
|
|
5
|
+
export const SUPPORTED_COST_CURRENCIES = ["USD", "CNY", "EUR", "GBP", "JPY", "CAD", "AUD", "CHF", "INR", "KRW"] as const;
|
|
6
|
+
export type CostCurrencyCode = typeof SUPPORTED_COST_CURRENCIES[number];
|
|
7
|
+
|
|
8
|
+
const CURRENCY_SYMBOLS: Record<CostCurrencyCode, string> = {
|
|
9
|
+
USD: "$",
|
|
10
|
+
CNY: "¥",
|
|
11
|
+
EUR: "€",
|
|
12
|
+
GBP: "£",
|
|
13
|
+
JPY: "¥",
|
|
14
|
+
CAD: "CA$",
|
|
15
|
+
AUD: "A$",
|
|
16
|
+
CHF: "CHF ",
|
|
17
|
+
INR: "₹",
|
|
18
|
+
KRW: "₩",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const RATE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
const RATE_URL = "https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@latest/v1/currencies/usd.min.json";
|
|
23
|
+
const RATE_CACHE_PATH = getAgentPath("powerline-footer", "currency-rates.json");
|
|
24
|
+
|
|
25
|
+
type RateTable = Partial<Record<CostCurrencyCode, number>>;
|
|
26
|
+
|
|
27
|
+
interface CachedRates {
|
|
28
|
+
timestamp: number;
|
|
29
|
+
rates: RateTable;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let cachedRates: CachedRates | null = null;
|
|
33
|
+
let pendingFetch: Promise<void> | null = null;
|
|
34
|
+
|
|
35
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return typeof value === "object" && value !== null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeCostCurrency(value: unknown): CostCurrencyCode | undefined {
|
|
40
|
+
if (typeof value !== "string") return undefined;
|
|
41
|
+
const code = value.trim().toUpperCase();
|
|
42
|
+
return (SUPPORTED_COST_CURRENCIES as readonly string[]).includes(code) ? code as CostCurrencyCode : undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function parseCachedRates(value: unknown): CachedRates | null {
|
|
46
|
+
if (!isRecord(value) || typeof value.timestamp !== "number" || !isRecord(value.rates)) return null;
|
|
47
|
+
|
|
48
|
+
const rates: RateTable = { USD: 1 };
|
|
49
|
+
for (const currency of SUPPORTED_COST_CURRENCIES) {
|
|
50
|
+
const rate = value.rates[currency.toLowerCase()] ?? value.rates[currency];
|
|
51
|
+
if (typeof rate === "number" && Number.isFinite(rate) && rate > 0) {
|
|
52
|
+
rates[currency] = rate;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { timestamp: value.timestamp, rates };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function readCachedRatesFromDisk(): Promise<CachedRates | null> {
|
|
60
|
+
try {
|
|
61
|
+
return parseCachedRates(JSON.parse(await readFile(RATE_CACHE_PATH, "utf8")));
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writeCachedRatesToDisk(rates: CachedRates): Promise<void> {
|
|
68
|
+
try {
|
|
69
|
+
await mkdir(dirname(RATE_CACHE_PATH), { recursive: true });
|
|
70
|
+
await writeFile(RATE_CACHE_PATH, JSON.stringify(rates));
|
|
71
|
+
} catch {
|
|
72
|
+
// The footer can still use in-memory rates when the cache file is unavailable.
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function fetchLatestRates(): Promise<CachedRates> {
|
|
77
|
+
const response = await fetch(RATE_URL);
|
|
78
|
+
if (!response.ok) throw new Error(`currency rate fetch failed with HTTP ${response.status}`);
|
|
79
|
+
|
|
80
|
+
const body = await response.json();
|
|
81
|
+
if (!isRecord(body) || !isRecord(body.usd)) throw new Error("currency rate response did not include USD rates");
|
|
82
|
+
|
|
83
|
+
const rates: RateTable = { USD: 1 };
|
|
84
|
+
for (const currency of SUPPORTED_COST_CURRENCIES) {
|
|
85
|
+
if (currency === "USD") continue;
|
|
86
|
+
const rate = body.usd[currency.toLowerCase()];
|
|
87
|
+
if (typeof rate === "number" && Number.isFinite(rate) && rate > 0) {
|
|
88
|
+
rates[currency] = rate;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { timestamp: Date.now(), rates };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ensureRatesRefreshing(now: number): void {
|
|
96
|
+
if (pendingFetch) return;
|
|
97
|
+
if (cachedRates && now - cachedRates.timestamp < RATE_TTL_MS) return;
|
|
98
|
+
|
|
99
|
+
pendingFetch = (async () => {
|
|
100
|
+
if (!cachedRates) cachedRates = await readCachedRatesFromDisk();
|
|
101
|
+
if (cachedRates && Date.now() - cachedRates.timestamp < RATE_TTL_MS) return;
|
|
102
|
+
if (typeof fetch !== "function") return;
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const rates = await fetchLatestRates();
|
|
106
|
+
cachedRates = rates;
|
|
107
|
+
void writeCachedRatesToDisk(rates);
|
|
108
|
+
} catch {
|
|
109
|
+
if (!cachedRates) cachedRates = await readCachedRatesFromDisk();
|
|
110
|
+
}
|
|
111
|
+
})().finally(() => {
|
|
112
|
+
pendingFetch = null;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getRate(currency: CostCurrencyCode): number | null {
|
|
117
|
+
if (currency === "USD") return 1;
|
|
118
|
+
|
|
119
|
+
const rate = cachedRates?.rates[currency];
|
|
120
|
+
ensureRatesRefreshing(Date.now());
|
|
121
|
+
|
|
122
|
+
return typeof rate === "number" && Number.isFinite(rate) && rate > 0 ? rate : null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function formatUsdCost(amountUsd: number, currency: CostCurrencyCode = "USD"): string | null {
|
|
126
|
+
const rate = getRate(currency);
|
|
127
|
+
if (!rate) return `-- ${currency}`;
|
|
128
|
+
|
|
129
|
+
const amount = amountUsd * rate;
|
|
130
|
+
const decimals = currency === "JPY" || currency === "KRW" ? 0 : 2;
|
|
131
|
+
return `${CURRENCY_SYMBOLS[currency]}${amount.toFixed(decimals)}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function __setCurrencyRatesForTest(rates: RateTable, timestamp = Date.now()): void {
|
|
135
|
+
cachedRates = { timestamp, rates: { USD: 1, ...rates } };
|
|
136
|
+
pendingFetch = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function __resetCurrencyRatesForTest(): void {
|
|
140
|
+
cachedRates = null;
|
|
141
|
+
pendingFetch = null;
|
|
142
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { AutocompleteProvider } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
export function getEditorAutocompleteProvider(sourceEditor: unknown): AutocompleteProvider | undefined {
|
|
4
|
+
const candidate = sourceEditor && typeof sourceEditor === "object" ? Reflect.get(sourceEditor, "autocompleteProvider") : null;
|
|
5
|
+
if (!candidate || typeof candidate !== "object") {
|
|
6
|
+
return undefined;
|
|
7
|
+
}
|
|
8
|
+
if (typeof Reflect.get(candidate, "getSuggestions") !== "function") {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (typeof Reflect.get(candidate, "applyCompletion") !== "function") {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
return candidate;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function passAutocompleteProviderThroughPreviousEditor(
|
|
18
|
+
provider: AutocompleteProvider,
|
|
19
|
+
previousEditor: unknown,
|
|
20
|
+
): AutocompleteProvider {
|
|
21
|
+
if (!previousEditor || typeof previousEditor !== "object") {
|
|
22
|
+
return provider;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const setAutocompleteProvider = Reflect.get(previousEditor, "setAutocompleteProvider");
|
|
26
|
+
if (typeof setAutocompleteProvider !== "function") {
|
|
27
|
+
return getEditorAutocompleteProvider(previousEditor) ?? provider;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
setAutocompleteProvider.call(previousEditor, provider);
|
|
31
|
+
return getEditorAutocompleteProvider(previousEditor) ?? provider;
|
|
32
|
+
}
|
|
@@ -13,14 +13,37 @@ interface CachedBranch {
|
|
|
13
13
|
timestamp: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
export type GitPollingMode = "full" | "branch" | "off";
|
|
17
|
+
|
|
18
|
+
/** Known git hosting providers we render a dedicated icon for. */
|
|
19
|
+
export type GitHost = "github" | "gitlab" | "bitbucket" | "other";
|
|
20
|
+
|
|
21
|
+
interface CachedRemoteHost {
|
|
22
|
+
host: GitHost | null;
|
|
23
|
+
timestamp: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
const CACHE_TTL_MS = 1000; // 1 second for file status
|
|
17
27
|
const BRANCH_TTL_MS = 500; // Shorter TTL so branch updates quickly after invalidation
|
|
28
|
+
const REMOTE_TTL_MS = 60_000; // Origin remote almost never changes within a session
|
|
18
29
|
let cachedStatus: CachedGitStatus | null = null;
|
|
19
30
|
let cachedBranch: CachedBranch | null = null;
|
|
31
|
+
let cachedRemoteHost: CachedRemoteHost | null = null;
|
|
32
|
+
let pendingRemoteFetch: Promise<void> | null = null;
|
|
20
33
|
let pendingFetch: Promise<void> | null = null;
|
|
21
34
|
let pendingBranchFetch: Promise<void> | null = null;
|
|
22
35
|
let invalidationCounter = 0; // Track invalidations to prevent stale updates
|
|
23
36
|
let branchInvalidationCounter = 0;
|
|
37
|
+
const updateListeners = new Set<() => void>();
|
|
38
|
+
|
|
39
|
+
function notifyGitUpdate(): void {
|
|
40
|
+
for (const listener of updateListeners) listener();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function subscribeGitUpdates(listener: () => void): () => void {
|
|
44
|
+
updateListeners.add(listener);
|
|
45
|
+
return () => updateListeners.delete(listener);
|
|
46
|
+
}
|
|
24
47
|
|
|
25
48
|
/**
|
|
26
49
|
* Parse git status --porcelain output
|
|
@@ -108,6 +131,73 @@ async function fetchGitBranch(): Promise<string | null> {
|
|
|
108
131
|
return sha ? `${sha} (detached)` : "detached";
|
|
109
132
|
}
|
|
110
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Classify an origin remote URL into a known hosting provider. Handles both
|
|
136
|
+
* SSH (`git@host:owner/repo`, `ssh://git@host/…`) and HTTP(S) forms, and
|
|
137
|
+
* treats sub-domains (e.g. `www.github.com`) and any non-empty remote we
|
|
138
|
+
* don't recognize as a generic git host.
|
|
139
|
+
*/
|
|
140
|
+
export function detectGitHost(remoteUrl: string | null): GitHost | null {
|
|
141
|
+
if (!remoteUrl) return null;
|
|
142
|
+
const trimmed = remoteUrl.trim();
|
|
143
|
+
if (!trimmed) return null;
|
|
144
|
+
|
|
145
|
+
let host: string;
|
|
146
|
+
const scpLike = /^[^/@]+@([^:/]+):/.exec(trimmed);
|
|
147
|
+
if (scpLike) {
|
|
148
|
+
host = scpLike[1]!;
|
|
149
|
+
} else {
|
|
150
|
+
try {
|
|
151
|
+
host = new URL(trimmed).hostname;
|
|
152
|
+
} catch {
|
|
153
|
+
return "other";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
host = host.toLowerCase().replace(/^www\./, "");
|
|
158
|
+
if (host === "github.com" || host.endsWith(".github.com")) return "github";
|
|
159
|
+
if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
|
|
160
|
+
if (host === "bitbucket.org" || host.endsWith(".bitbucket.org")) return "bitbucket";
|
|
161
|
+
return "other";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Fetch the origin remote host asynchronously and cache the result.
|
|
166
|
+
*/
|
|
167
|
+
async function fetchRemoteHost(): Promise<GitHost | null> {
|
|
168
|
+
const url = await runGit(["remote", "get-url", "origin"]);
|
|
169
|
+
return detectGitHost(url);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the origin remote's hosting provider with a long TTL cache. Returns the
|
|
174
|
+
* cached value immediately (or null before the first fetch completes) and
|
|
175
|
+
* refreshes in the background, matching the branch/status caching pattern.
|
|
176
|
+
*/
|
|
177
|
+
export function getGitRemoteHost(): GitHost | null {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (cachedRemoteHost && now - cachedRemoteHost.timestamp < REMOTE_TTL_MS) {
|
|
180
|
+
return cachedRemoteHost.host;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!pendingRemoteFetch) {
|
|
184
|
+
pendingRemoteFetch = fetchRemoteHost()
|
|
185
|
+
.then((host) => {
|
|
186
|
+
cachedRemoteHost = { host, timestamp: Date.now() };
|
|
187
|
+
notifyGitUpdate();
|
|
188
|
+
})
|
|
189
|
+
.catch(() => {
|
|
190
|
+
cachedRemoteHost = { host: null, timestamp: Date.now() };
|
|
191
|
+
notifyGitUpdate();
|
|
192
|
+
})
|
|
193
|
+
.finally(() => {
|
|
194
|
+
pendingRemoteFetch = null;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return cachedRemoteHost ? cachedRemoteHost.host : null;
|
|
199
|
+
}
|
|
200
|
+
|
|
111
201
|
/**
|
|
112
202
|
* Fetch git status asynchronously
|
|
113
203
|
*/
|
|
@@ -139,6 +229,7 @@ export function getCurrentBranch(providerBranch: string | null): string | null {
|
|
|
139
229
|
branch: result,
|
|
140
230
|
timestamp: Date.now(),
|
|
141
231
|
};
|
|
232
|
+
notifyGitUpdate();
|
|
142
233
|
}
|
|
143
234
|
pendingBranchFetch = null;
|
|
144
235
|
});
|
|
@@ -154,9 +245,13 @@ export function getCurrentBranch(providerBranch: string | null): string | null {
|
|
|
154
245
|
* This is designed for synchronous render() calls - returns last known value
|
|
155
246
|
* while refreshing in background.
|
|
156
247
|
*/
|
|
157
|
-
export function getGitStatus(providerBranch: string | null): GitStatus {
|
|
248
|
+
export function getGitStatus(providerBranch: string | null, pollingMode: GitPollingMode = "full"): GitStatus {
|
|
158
249
|
const now = Date.now();
|
|
159
|
-
const branch = getCurrentBranch(providerBranch);
|
|
250
|
+
const branch = pollingMode === "off" ? providerBranch : getCurrentBranch(providerBranch);
|
|
251
|
+
|
|
252
|
+
if (pollingMode !== "full") {
|
|
253
|
+
return { branch, staged: 0, unstaged: 0, untracked: 0 };
|
|
254
|
+
}
|
|
160
255
|
|
|
161
256
|
// Return cached if fresh
|
|
162
257
|
if (cachedStatus && now - cachedStatus.timestamp < CACHE_TTL_MS) {
|
|
@@ -177,6 +272,7 @@ export function getGitStatus(providerBranch: string | null): GitStatus {
|
|
|
177
272
|
cachedStatus = result
|
|
178
273
|
? { staged: result.staged, unstaged: result.unstaged, untracked: result.untracked, timestamp: Date.now() }
|
|
179
274
|
: { staged: 0, unstaged: 0, untracked: 0, timestamp: Date.now() };
|
|
275
|
+
notifyGitUpdate();
|
|
180
276
|
}
|
|
181
277
|
pendingFetch = null;
|
|
182
278
|
});
|
|
@@ -196,17 +292,23 @@ export function getGitStatus(providerBranch: string | null): GitStatus {
|
|
|
196
292
|
}
|
|
197
293
|
|
|
198
294
|
/**
|
|
199
|
-
* Force refresh git status (call when you know files changed)
|
|
295
|
+
* Force refresh git status (call when you know files changed).
|
|
296
|
+
* Serve-stale: keep the last known counts on screen while a background
|
|
297
|
+
* refresh runs, instead of blanking the segment to zeros (footer flicker).
|
|
200
298
|
*/
|
|
201
299
|
export function invalidateGitStatus(): void {
|
|
202
|
-
cachedStatus =
|
|
300
|
+
if (cachedStatus) cachedStatus.timestamp = 0; // expire, but keep serving the stale value
|
|
203
301
|
invalidationCounter++; // Increment to invalidate any pending fetches
|
|
204
302
|
}
|
|
205
303
|
|
|
206
304
|
/**
|
|
207
|
-
* Force refresh git branch (call when you know branch might have changed)
|
|
305
|
+
* Force refresh git branch (call when you know branch might have changed).
|
|
306
|
+
* Serve-stale: keep showing the last known branch until the refresh lands.
|
|
208
307
|
*/
|
|
209
308
|
export function invalidateGitBranch(): void {
|
|
210
|
-
cachedBranch =
|
|
309
|
+
if (cachedBranch) cachedBranch.timestamp = 0; // expire, but keep serving the stale value
|
|
211
310
|
branchInvalidationCounter++;
|
|
311
|
+
// The origin remote is repo-scoped, so a branch/cwd change may mean a
|
|
312
|
+
// different repo; drop the host cache so it re-detects.
|
|
313
|
+
cachedRemoteHost = null;
|
|
212
314
|
}
|
|
@@ -149,7 +149,7 @@ export function getGuidePreferences(settings: unknown): GuidePreferences {
|
|
|
149
149
|
: {};
|
|
150
150
|
|
|
151
151
|
return {
|
|
152
|
-
mode: isGuideMode(raw.mode) ? raw.mode : "
|
|
152
|
+
mode: isGuideMode(raw.mode) ? raw.mode : "compact",
|
|
153
153
|
lastSeenVersion: typeof raw.lastSeenVersion === "string" ? raw.lastSeenVersion : undefined,
|
|
154
154
|
};
|
|
155
155
|
}
|
|
@@ -6,6 +6,9 @@ export interface IconSet {
|
|
|
6
6
|
folder: string;
|
|
7
7
|
branch: string;
|
|
8
8
|
git: string;
|
|
9
|
+
github: string;
|
|
10
|
+
gitlab: string;
|
|
11
|
+
bitbucket: string;
|
|
9
12
|
tokens: string;
|
|
10
13
|
context: string;
|
|
11
14
|
cost: string;
|
|
@@ -56,8 +59,11 @@ export const NERD_ICONS: IconSet = {
|
|
|
56
59
|
folder: "\uF115", // nf-fa-folder_open
|
|
57
60
|
branch: "\uF126", // nf-fa-code_fork (git branch)
|
|
58
61
|
git: "\uF1D3", // nf-fa-git (git logo)
|
|
62
|
+
github: "\uF09B", // nf-fa-github (octocat)
|
|
63
|
+
gitlab: "\uF296", // nf-fa-gitlab (tanuki)
|
|
64
|
+
bitbucket: "\uF171", // nf-fa-bitbucket
|
|
59
65
|
tokens: "\uE26B", // nf-seti-html (tokens symbol)
|
|
60
|
-
context: "\
|
|
66
|
+
context: "\uF1C0", // nf-fa-database (stable Nerd Fonts v3 database)
|
|
61
67
|
cost: "\uF155", // nf-fa-dollar
|
|
62
68
|
time: "\uF017", // nf-fa-clock_o
|
|
63
69
|
agents: "\uF0C0", // nf-fa-users
|
|
@@ -77,6 +83,9 @@ export const ASCII_ICONS: IconSet = {
|
|
|
77
83
|
folder: "dir",
|
|
78
84
|
branch: "⎇",
|
|
79
85
|
git: "⎇",
|
|
86
|
+
github: "⎇",
|
|
87
|
+
gitlab: "⎇",
|
|
88
|
+
bitbucket: "⎇",
|
|
80
89
|
tokens: "⊛",
|
|
81
90
|
context: "◫",
|
|
82
91
|
cost: "$",
|
|
@@ -101,7 +110,7 @@ function sanitizeUserIconOverrides(value: unknown): PartialIconSet {
|
|
|
101
110
|
const sanitized: PartialIconSet = {};
|
|
102
111
|
const validKeys = Object.keys(NERD_ICONS) as Array<keyof IconSet>;
|
|
103
112
|
for (const key of validKeys) {
|
|
104
|
-
const icon = value
|
|
113
|
+
const icon = Reflect.get(value, key);
|
|
105
114
|
if (typeof icon === "string") {
|
|
106
115
|
sanitized[key] = icon;
|
|
107
116
|
}
|