@selesai/code 0.7.0 → 0.8.0

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 (72) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +4 -2
  3. package/dist/extensions/agent-browser.test.ts +195 -0
  4. package/dist/extensions/agent-browser.ts +158 -0
  5. package/dist/extensions/inline-skills.test.ts +14 -0
  6. package/dist/extensions/inline-skills.ts +14 -7
  7. package/dist/extensions/package.json +2 -1
  8. package/dist/extensions/pi-powerline-footer/.github/workflows/test.yml +27 -0
  9. package/dist/extensions/pi-powerline-footer/CHANGELOG.md +153 -0
  10. package/dist/extensions/pi-powerline-footer/CONTRIBUTING.md +16 -0
  11. package/dist/extensions/pi-powerline-footer/README.md +192 -44
  12. package/dist/extensions/pi-powerline-footer/bash-mode/completion.ts +19 -4
  13. package/dist/extensions/pi-powerline-footer/bash-mode/editor.ts +80 -25
  14. package/dist/extensions/pi-powerline-footer/bash-mode/history.ts +81 -29
  15. package/dist/extensions/pi-powerline-footer/bash-mode/types.ts +1 -1
  16. package/dist/extensions/pi-powerline-footer/cd-command.ts +190 -0
  17. package/dist/extensions/pi-powerline-footer/colors.ts +2 -1
  18. package/dist/extensions/pi-powerline-footer/context-usage.ts +121 -6
  19. package/dist/extensions/pi-powerline-footer/currency-rates.ts +142 -0
  20. package/dist/extensions/pi-powerline-footer/editor-composition.ts +32 -0
  21. package/dist/extensions/pi-powerline-footer/git-status.ts +108 -6
  22. package/dist/extensions/pi-powerline-footer/icons.ts +11 -2
  23. package/dist/extensions/pi-powerline-footer/index.ts +1056 -324
  24. package/dist/extensions/pi-powerline-footer/lifecycle.ts +10 -0
  25. package/dist/extensions/pi-powerline-footer/package.json +10 -6
  26. package/dist/extensions/pi-powerline-footer/paths.ts +22 -0
  27. package/dist/extensions/pi-powerline-footer/powerline-config.ts +297 -15
  28. package/dist/extensions/pi-powerline-footer/presets.ts +6 -25
  29. package/dist/extensions/pi-powerline-footer/queue/store.ts +361 -0
  30. package/dist/extensions/pi-powerline-footer/queue/types.ts +54 -0
  31. package/dist/extensions/pi-powerline-footer/render-scheduler.ts +12 -7
  32. package/dist/extensions/pi-powerline-footer/segments.ts +114 -107
  33. package/dist/extensions/pi-powerline-footer/session-usage.ts +1 -1
  34. package/dist/extensions/pi-powerline-footer/shortcuts.ts +17 -3
  35. package/dist/extensions/pi-powerline-footer/tests/bash-mode.test.ts +189 -17
  36. package/dist/extensions/pi-powerline-footer/tests/cd-command.test.ts +144 -0
  37. package/dist/extensions/pi-powerline-footer/tests/context-usage.test.ts +120 -2
  38. package/dist/extensions/pi-powerline-footer/tests/custom-items.test.ts +245 -22
  39. package/dist/extensions/pi-powerline-footer/tests/editor-composition.test.ts +48 -0
  40. package/dist/extensions/pi-powerline-footer/tests/editor-responsiveness.test.ts +34 -0
  41. package/dist/extensions/pi-powerline-footer/tests/fixed-editor-border.test.ts +39 -0
  42. package/dist/extensions/pi-powerline-footer/tests/git-status.test.ts +92 -0
  43. package/dist/extensions/pi-powerline-footer/tests/guide.test.ts +4 -1
  44. package/dist/extensions/pi-powerline-footer/tests/jump-shortcuts.test.ts +33 -197
  45. package/dist/extensions/pi-powerline-footer/tests/mode-aware-trigger.test.ts +79 -0
  46. package/dist/extensions/pi-powerline-footer/tests/paths.test.ts +63 -0
  47. package/dist/extensions/pi-powerline-footer/tests/queue-store.test.ts +195 -0
  48. package/dist/extensions/pi-powerline-footer/tests/quit-cursor.test.ts +14 -0
  49. package/dist/extensions/pi-powerline-footer/tests/remaining-regressions.test.ts +167 -0
  50. package/dist/extensions/pi-powerline-footer/tests/stash-shortcut.test.ts +23 -18
  51. package/dist/extensions/pi-powerline-footer/tests/thinking-segment.test.ts +16 -1
  52. package/dist/extensions/pi-powerline-footer/tests/token-stats.test.ts +249 -0
  53. package/dist/extensions/pi-powerline-footer/tests/usage-display.test.ts +237 -0
  54. package/dist/extensions/pi-powerline-footer/tests/welcome.test.ts +199 -0
  55. package/dist/extensions/pi-powerline-footer/tests/working-vibes.test.ts +191 -100
  56. package/dist/extensions/pi-powerline-footer/theme.ts +32 -18
  57. package/dist/extensions/pi-powerline-footer/token-stats.ts +304 -0
  58. package/dist/extensions/pi-powerline-footer/tsconfig.json +13 -0
  59. package/dist/extensions/pi-powerline-footer/types.ts +74 -29
  60. package/dist/extensions/pi-powerline-footer/welcome-dismiss.ts +34 -0
  61. package/dist/extensions/pi-powerline-footer/welcome.ts +65 -10
  62. package/dist/extensions/pi-powerline-footer/working-vibes.ts +44 -11
  63. package/dist/extensions/pi-subagents/package-lock.json +2 -2
  64. package/dist/extensions/pi-subagents/package.json +1 -1
  65. package/dist/extensions/pi-subagents/src/slash/inline-subagents.ts +57 -24
  66. package/dist/extensions/pi-subagents/test/unit/inline-subagents.test.ts +82 -7
  67. package/dist/extensions/pi-subagents/test/unit/package-manifest.test.ts +1 -1
  68. package/dist/extensions/ponytail/index.js +1 -31
  69. package/dist/extensions/ponytail/test/extension.test.js +5 -22
  70. package/dist/extensions/rtk.test.ts +27 -12
  71. package/dist/extensions/rtk.ts +41 -35
  72. package/package.json +1 -1
@@ -1,11 +1,123 @@
1
- interface CoreContextUsage {
2
- contextTokens: number;
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 !== "number"
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 = null;
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 = null;
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
  }
@@ -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: "\uE70F", // nf-dev-database (database)
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[key];
113
+ const icon = Reflect.get(value, key);
105
114
  if (typeof icon === "string") {
106
115
  sanitized[key] = icon;
107
116
  }