Skip to content

Commit 6d2c58a

Browse files
christsoclaude
andauthored
perf(tui): session-scoped caching to eliminate progressive slowdown (#59)
* feat(tui): add TuiCache class for session-scoped caching Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(tui): make getTuiContext() use session cache Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf(tui): wire session cache into wizard loop and invalidate after writes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf(tui): cache marketplace plugin lists in browse/install flows Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(tui): remove non-null assertion to satisfy biome lint Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(test): update workspace status e2e test for user workspace fallback The test expected exit code 1 for workspace status in a non-workspace dir, but since the user workspace fallback was added, the command now succeeds with empty data. Also added HOME mock for environment isolation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf(tui): cache listMarketplaces() calls and remove dead registry code Replace unused MarketplaceRegistry cache with MarketplaceEntry[] cache that matches what listMarketplaces() returns. Wire cached marketplace list into runInstallPlugin, runBrowseMarketplaces, and getTuiContext. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c5d4fec commit 6d2c58a

7 files changed

Lines changed: 246 additions & 19 deletions

File tree

src/cli/tui/actions/plugins.ts

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,41 @@ import {
88
addMarketplace,
99
removeMarketplace,
1010
updateMarketplace,
11+
type MarketplaceEntry,
12+
type MarketplacePluginsResult,
1113
} from '../../../core/marketplace.js';
1214
import { getWorkspaceStatus } from '../../../core/status.js';
1315
import type { TuiContext } from '../context.js';
16+
import type { TuiCache } from '../cache.js';
17+
18+
/**
19+
* Get marketplace list, using cache when available.
20+
*/
21+
async function getCachedMarketplaces(
22+
cache?: TuiCache,
23+
): Promise<MarketplaceEntry[]> {
24+
const cached = cache?.getMarketplaces();
25+
if (cached) return cached;
26+
27+
const result = await listMarketplaces();
28+
cache?.setMarketplaces(result);
29+
return result;
30+
}
31+
32+
/**
33+
* Get marketplace plugins, using cache when available.
34+
*/
35+
async function getCachedMarketplacePlugins(
36+
name: string,
37+
cache?: TuiCache,
38+
): Promise<MarketplacePluginsResult> {
39+
const cached = cache?.getMarketplacePlugins(name);
40+
if (cached) return cached;
41+
42+
const result = await listMarketplacePlugins(name);
43+
cache?.setMarketplacePlugins(name, result);
44+
return result;
45+
}
1446

1547
/**
1648
* Shared helper: determine scope, install a plugin, sync, and show success.
@@ -19,6 +51,7 @@ import type { TuiContext } from '../context.js';
1951
async function installSelectedPlugin(
2052
pluginRef: string,
2153
context: TuiContext,
54+
cache?: TuiCache,
2255
): Promise<boolean> {
2356
// Determine scope
2457
let scope: 'project' | 'user' = 'user';
@@ -69,6 +102,7 @@ async function installSelectedPlugin(
69102
syncS.stop('Sync complete');
70103
}
71104

105+
cache?.invalidate();
72106
p.note(`Installed: ${pluginRef}`, 'Success');
73107
return true;
74108
}
@@ -77,10 +111,10 @@ async function installSelectedPlugin(
77111
* Plugin installation flow.
78112
* Lists marketplace plugins, lets user pick one, installs it, and auto-syncs.
79113
*/
80-
export async function runInstallPlugin(context: TuiContext): Promise<void> {
114+
export async function runInstallPlugin(context: TuiContext, cache?: TuiCache): Promise<void> {
81115
try {
82116
// Get available marketplaces
83-
const marketplaces = await listMarketplaces();
117+
const marketplaces = await getCachedMarketplaces(cache);
84118

85119
if (marketplaces.length === 0) {
86120
p.note(
@@ -93,7 +127,7 @@ export async function runInstallPlugin(context: TuiContext): Promise<void> {
93127
// Collect plugins from all marketplaces
94128
const allPlugins: Array<{ label: string; value: string }> = [];
95129
for (const marketplace of marketplaces) {
96-
const result = await listMarketplacePlugins(marketplace.name);
130+
const result = await getCachedMarketplacePlugins(marketplace.name, cache);
97131
for (const plugin of result.plugins) {
98132
const label = plugin.description
99133
? `${plugin.name} - ${plugin.description}`
@@ -119,7 +153,7 @@ export async function runInstallPlugin(context: TuiContext): Promise<void> {
119153
return;
120154
}
121155

122-
await installSelectedPlugin(selected, context);
156+
await installSelectedPlugin(selected, context, cache);
123157
} catch (error) {
124158
const message = error instanceof Error ? error.message : String(error);
125159
p.note(message, 'Error');
@@ -130,7 +164,7 @@ export async function runInstallPlugin(context: TuiContext): Promise<void> {
130164
* Plugin management (uninstall) flow.
131165
* Lists installed plugins, lets user pick which to remove, and auto-syncs.
132166
*/
133-
export async function runManagePlugins(context: TuiContext): Promise<void> {
167+
export async function runManagePlugins(context: TuiContext, cache?: TuiCache): Promise<void> {
134168
try {
135169
const status = await getWorkspaceStatus(context.workspacePath ?? undefined);
136170

@@ -202,6 +236,7 @@ export async function runManagePlugins(context: TuiContext): Promise<void> {
202236
await syncUserWorkspace();
203237
}
204238
syncS.stop('Sync complete');
239+
cache?.invalidate();
205240

206241
p.note(results.join('\n'), 'Removed');
207242
} catch (error) {
@@ -216,10 +251,11 @@ export async function runManagePlugins(context: TuiContext): Promise<void> {
216251
*/
217252
export async function runBrowseMarketplaces(
218253
context: TuiContext,
254+
cache?: TuiCache,
219255
): Promise<void> {
220256
try {
221257
while (true) {
222-
const marketplaces = await listMarketplaces();
258+
const marketplaces = await getCachedMarketplaces(cache);
223259

224260
const options: Array<{ label: string; value: string }> = [
225261
{ label: '+ Add marketplace', value: '__add__' },
@@ -258,13 +294,15 @@ export async function runBrowseMarketplaces(
258294

259295
if (!result.success) {
260296
p.note(result.error ?? 'Unknown error', 'Error');
297+
} else {
298+
cache?.invalidate();
261299
}
262300

263301
continue;
264302
}
265303

266304
// User selected a marketplace — show detail screen
267-
await runMarketplaceDetail(selected, context);
305+
await runMarketplaceDetail(selected, context, cache);
268306
}
269307
} catch (error) {
270308
const message = error instanceof Error ? error.message : String(error);
@@ -279,6 +317,7 @@ export async function runBrowseMarketplaces(
279317
async function runMarketplaceDetail(
280318
marketplaceName: string,
281319
context: TuiContext,
320+
cache?: TuiCache,
282321
): Promise<void> {
283322
while (true) {
284323
const action = await p.select({
@@ -297,7 +336,7 @@ async function runMarketplaceDetail(
297336

298337
if (action === 'browse') {
299338
try {
300-
const result = await listMarketplacePlugins(marketplaceName);
339+
const result = await getCachedMarketplacePlugins(marketplaceName, cache);
301340

302341
if (result.plugins.length === 0) {
303342
p.note('No plugins found in this marketplace.', 'Plugins');
@@ -323,7 +362,7 @@ async function runMarketplaceDetail(
323362
}
324363

325364
const pluginRef = `${selectedPlugin}@${marketplaceName}`;
326-
await installSelectedPlugin(pluginRef, context);
365+
await installSelectedPlugin(pluginRef, context, cache);
327366
} catch (error) {
328367
const message = error instanceof Error ? error.message : String(error);
329368
p.note(message, 'Error');
@@ -344,6 +383,7 @@ async function runMarketplaceDetail(
344383
)
345384
.join('\n');
346385
s.stop('Update complete');
386+
cache?.invalidate();
347387
p.note(summary || 'Marketplace updated.', 'Update');
348388
} catch (error) {
349389
const message = error instanceof Error ? error.message : String(error);
@@ -376,6 +416,8 @@ async function runMarketplaceDetail(
376416
p.note(result.error ?? 'Unknown error', 'Error');
377417
continue;
378418
}
419+
420+
cache?.invalidate();
379421
} catch (error) {
380422
const message = error instanceof Error ? error.message : String(error);
381423
p.note(message, 'Error');

src/cli/tui/cache.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import type { MarketplaceEntry, MarketplacePluginsResult } from '../../core/marketplace.js';
2+
import type { TuiContext } from './context.js';
3+
4+
/**
5+
* Session-scoped cache for the TUI wizard.
6+
* Holds expensive-to-compute data (registry, plugin lists, context) in memory.
7+
* Call invalidate() after any write operation (install, remove, sync, etc.).
8+
*/
9+
export class TuiCache {
10+
private marketplaces: MarketplaceEntry[] | undefined;
11+
private context: TuiContext | undefined;
12+
private marketplacePlugins: Map<string, MarketplacePluginsResult> = new Map();
13+
14+
/** Get cached marketplace list (undefined if not cached) */
15+
getMarketplaces(): MarketplaceEntry[] | undefined {
16+
return this.marketplaces;
17+
}
18+
19+
/** Store marketplace list in cache */
20+
setMarketplaces(marketplaces: MarketplaceEntry[]): void {
21+
this.marketplaces = marketplaces;
22+
}
23+
24+
/** Check if context is cached */
25+
hasCachedContext(): boolean {
26+
return this.context !== undefined;
27+
}
28+
29+
/** Get cached context (undefined if not cached) */
30+
getContext(): TuiContext | undefined {
31+
return this.context;
32+
}
33+
34+
/** Store context in cache */
35+
setContext(context: TuiContext): void {
36+
this.context = context;
37+
}
38+
39+
/** Get cached marketplace plugins for a specific marketplace */
40+
getMarketplacePlugins(name: string): MarketplacePluginsResult | undefined {
41+
return this.marketplacePlugins.get(name);
42+
}
43+
44+
/** Store marketplace plugins in cache */
45+
setMarketplacePlugins(name: string, result: MarketplacePluginsResult): void {
46+
this.marketplacePlugins.set(name, result);
47+
}
48+
49+
/** Clear all cached data. Call after any write operation. */
50+
invalidate(): void {
51+
this.marketplaces = undefined;
52+
this.context = undefined;
53+
this.marketplacePlugins.clear();
54+
}
55+
}

src/cli/tui/context.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getWorkspaceStatus } from '../../core/status.js';
55
import { loadSyncState } from '../../core/sync-state.js';
66
import { getUserWorkspaceConfig } from '../../core/user-workspace.js';
77
import { listMarketplaces } from '../../core/marketplace.js';
8+
import type { TuiCache } from './cache.js';
89

910
/**
1011
* Workspace context detected at TUI startup and after each action.
@@ -26,7 +27,13 @@ export interface TuiContext {
2627
*/
2728
export async function getTuiContext(
2829
cwd: string = process.cwd(),
30+
cache?: TuiCache,
2931
): Promise<TuiContext> {
32+
// Return cached context if available
33+
const cachedContext = cache?.getContext();
34+
if (cachedContext) {
35+
return cachedContext;
36+
}
3037
const configPath = join(cwd, CONFIG_DIR, WORKSPACE_CONFIG_FILE);
3138
const hasWorkspace = existsSync(configPath);
3239

@@ -67,13 +74,19 @@ export async function getTuiContext(
6774
// Marketplace count
6875
let marketplaceCount = 0;
6976
try {
70-
const marketplaces = await listMarketplaces();
71-
marketplaceCount = marketplaces.length;
77+
const cachedMarketplaces = cache?.getMarketplaces();
78+
if (cachedMarketplaces) {
79+
marketplaceCount = cachedMarketplaces.length;
80+
} else {
81+
const marketplaces = await listMarketplaces();
82+
cache?.setMarketplaces(marketplaces);
83+
marketplaceCount = marketplaces.length;
84+
}
7285
} catch {
7386
// Marketplace listing failed -- degrade gracefully
7487
}
7588

76-
return {
89+
const context: TuiContext = {
7790
hasWorkspace,
7891
workspacePath: hasWorkspace ? cwd : null,
7992
projectPluginCount,
@@ -82,6 +95,9 @@ export async function getTuiContext(
8295
hasUserConfig,
8396
marketplaceCount,
8497
};
98+
99+
cache?.setContext(context);
100+
return context;
85101
}
86102

87103
/**

src/cli/tui/wizard.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as p from '@clack/prompts';
22
import chalk from 'chalk';
33
import { relative } from 'node:path';
44
import packageJson from '../../../package.json';
5+
import { TuiCache } from './cache.js';
56
import { getTuiContext, type TuiContext } from './context.js';
67
import { runInit } from './actions/init.js';
78
import { runSync } from './actions/sync.js';
@@ -91,7 +92,8 @@ function buildSummary(context: TuiContext): string {
9192
export async function runWizard(): Promise<void> {
9293
p.intro(`${chalk.cyan('allagents')} v${packageJson.version}`);
9394

94-
let context = await getTuiContext();
95+
const cache = new TuiCache();
96+
let context = await getTuiContext(process.cwd(), cache);
9597

9698
// biome-ignore lint/correctness/noConstantCondition: intentional wizard loop
9799
while (true) {
@@ -110,31 +112,34 @@ export async function runWizard(): Promise<void> {
110112
switch (action) {
111113
case 'init':
112114
await runInit();
115+
cache.invalidate();
113116
break;
114117
case 'sync':
115118
await runSync(context);
119+
cache.invalidate();
116120
break;
117121
case 'status':
118122
await runStatus(context);
119123
break;
120124
case 'install':
121-
await runInstallPlugin(context);
125+
await runInstallPlugin(context, cache);
122126
break;
123127
case 'manage':
124-
await runManagePlugins(context);
128+
await runManagePlugins(context, cache);
125129
break;
126130
case 'marketplace':
127-
await runBrowseMarketplaces(context);
131+
await runBrowseMarketplaces(context, cache);
128132
break;
129133
case 'update':
130134
await runUpdate();
135+
cache.invalidate();
131136
break;
132137
case 'exit':
133138
p.outro('Bye');
134139
return;
135140
}
136141

137-
// Refresh context after each action
138-
context = await getTuiContext();
142+
// Refresh context after each action (cache makes this cheap when nothing changed)
143+
context = await getTuiContext(process.cwd(), cache);
139144
}
140145
}

tests/e2e/cli-json-output.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ describe('CLI --json error cases', () => {
9191
});
9292

9393
it('workspace status --json in non-workspace dir falls back to user workspace', async () => {
94-
const { stdout, exitCode } = await runCli(['workspace', 'status', '--json']);
94+
const { stdout, exitCode } = await runCli(['workspace', 'status', '--json'], { HOME: mockHome });
9595
expect(exitCode).toBe(0);
9696
const json = parseJson(stdout);
9797
expect(json.success).toBe(true);

0 commit comments

Comments
 (0)