@pennyfarthing/cyclist 10.0.3 → 10.2.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.
- package/dist/api/agent-load.d.ts +3 -0
- package/dist/api/agent-load.d.ts.map +1 -0
- package/dist/api/agent-load.js +124 -0
- package/dist/api/agent-load.js.map +1 -0
- package/dist/api/code-markers.d.ts +9 -0
- package/dist/api/code-markers.d.ts.map +1 -0
- package/dist/api/code-markers.js +62 -0
- package/dist/api/code-markers.js.map +1 -0
- package/dist/api/complexity.d.ts +3 -0
- package/dist/api/complexity.d.ts.map +1 -0
- package/dist/api/complexity.js +47 -0
- package/dist/api/complexity.js.map +1 -0
- package/dist/api/dead-code.d.ts +3 -0
- package/dist/api/dead-code.d.ts.map +1 -0
- package/dist/api/dead-code.js +70 -0
- package/dist/api/dead-code.js.map +1 -0
- package/dist/api/dependencies.d.ts +3 -0
- package/dist/api/dependencies.d.ts.map +1 -0
- package/dist/api/dependencies.js +43 -0
- package/dist/api/dependencies.js.map +1 -0
- package/dist/api/git.d.ts +3 -2
- package/dist/api/git.d.ts.map +1 -1
- package/dist/api/git.js +11 -6
- package/dist/api/git.js.map +1 -1
- package/dist/api/health-score.d.ts +3 -0
- package/dist/api/health-score.d.ts.map +1 -0
- package/dist/api/health-score.js +47 -0
- package/dist/api/health-score.js.map +1 -0
- package/dist/api/hotspots.d.ts.map +1 -1
- package/dist/api/hotspots.js +9 -1
- package/dist/api/hotspots.js.map +1 -1
- package/dist/api/index.d.ts +7 -1
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +12 -2
- package/dist/api/index.js.map +1 -1
- package/dist/api/persona.d.ts +2 -0
- package/dist/api/persona.d.ts.map +1 -1
- package/dist/api/persona.js +19 -1
- package/dist/api/persona.js.map +1 -1
- package/dist/api/settings.js +1 -1
- package/dist/api/settings.js.map +1 -1
- package/dist/claude-service.d.ts +8 -2
- package/dist/claude-service.d.ts.map +1 -1
- package/dist/claude-service.js +21 -2
- package/dist/claude-service.js.map +1 -1
- package/dist/git-diff.d.ts.map +1 -1
- package/dist/git-diff.js +6 -5
- package/dist/git-diff.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +11 -2
- package/dist/main.js.map +1 -1
- package/dist/plugin-loader.d.ts +49 -0
- package/dist/plugin-loader.d.ts.map +1 -0
- package/dist/plugin-loader.js +92 -0
- package/dist/plugin-loader.js.map +1 -0
- package/dist/preload.js +12 -1
- package/dist/preload.js.map +1 -1
- package/dist/prime.d.ts +3 -2
- package/dist/prime.d.ts.map +1 -1
- package/dist/prime.js +25 -8
- package/dist/prime.js.map +1 -1
- package/dist/public/css/react.css +1 -1
- package/dist/public/js/react/react.js +50 -39
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +19 -16
- package/dist/server.js.map +1 -1
- package/dist/sprint-data.d.ts +6 -0
- package/dist/sprint-data.d.ts.map +1 -1
- package/dist/sprint-data.js +118 -67
- package/dist/sprint-data.js.map +1 -1
- package/dist/story-parser.js +1 -1
- package/dist/story-parser.js.map +1 -1
- package/dist/theme-metadata.js +2 -2
- package/dist/theme-metadata.js.map +1 -1
- package/dist/websocket.d.ts +0 -6
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +36 -40
- package/dist/websocket.js.map +1 -1
- package/package.json +2 -1
- package/portraits/fifth-element/large/cornelius-54343.png +0 -0
- package/portraits/fifth-element/large/diva-53453.png +0 -0
- package/portraits/fifth-element/large/korben-34232.png +0 -0
- package/portraits/fifth-element/large/leeloo-54333.png +0 -0
- package/portraits/fifth-element/large/lindberg-34432.png +0 -0
- package/portraits/fifth-element/large/mondoshawan-55131.png +0 -0
- package/portraits/fifth-element/large/munro-25321.png +0 -0
- package/portraits/fifth-element/large/pacoli-45232.png +0 -0
- package/portraits/fifth-element/large/ruby-53544.png +0 -0
- package/portraits/fifth-element/large/zorg-45312.png +0 -0
- package/portraits/fifth-element/medium/cornelius-54343.png +0 -0
- package/portraits/fifth-element/medium/diva-53453.png +0 -0
- package/portraits/fifth-element/medium/korben-34232.png +0 -0
- package/portraits/fifth-element/medium/leeloo-54333.png +0 -0
- package/portraits/fifth-element/medium/lindberg-34432.png +0 -0
- package/portraits/fifth-element/medium/mondoshawan-55131.png +0 -0
- package/portraits/fifth-element/medium/munro-25321.png +0 -0
- package/portraits/fifth-element/medium/pacoli-45232.png +0 -0
- package/portraits/fifth-element/medium/ruby-53544.png +0 -0
- package/portraits/fifth-element/medium/zorg-45312.png +0 -0
- package/src/public/App.tsx +0 -2
- package/src/public/components/AgentLoadDialog.tsx +202 -0
- package/src/public/components/AgentPopup.tsx +3 -5
- package/src/public/components/ContextSparkline.tsx +56 -0
- package/src/public/components/ControlBar.tsx +140 -6
- package/src/public/components/DeadCodeDialog.tsx +169 -0
- package/src/public/components/DockviewWorkspace.tsx +0 -3
- package/src/public/components/FullFileTree.tsx +18 -4
- package/src/public/components/HealthGauge.tsx +181 -0
- package/src/public/components/MessageView.tsx +23 -6
- package/src/public/components/PersonaHeader.tsx +46 -3
- package/src/public/components/TandemPortrait.tsx +71 -0
- package/src/public/components/ToolCallBlock.tsx +21 -6
- package/src/public/components/dialogs/CodeMarkersDialog.tsx +169 -0
- package/src/public/components/dialogs/ComplexityDialog.tsx +163 -0
- package/src/public/components/dialogs/DependenciesDialog.tsx +120 -0
- package/src/public/components/dialogs/HotspotsDialog.tsx +451 -0
- package/src/public/components/dialogs/ToolDialog.tsx +43 -0
- package/src/public/components/panels/ACPanel.tsx +1 -1
- package/src/public/components/panels/AcceptanceCriteriaPanel.tsx +15 -30
- package/src/public/components/panels/DebugPanel.tsx +79 -3
- package/src/public/components/panels/GitPanel.tsx +25 -30
- package/src/public/components/panels/MessagePanel.tsx +44 -2
- package/src/public/components/panels/SettingsPanel.tsx +4 -4
- package/src/public/components/panels/SprintPanel.tsx +247 -123
- package/src/public/components/panels/index.ts +0 -1
- package/src/public/components/ui/dialog.tsx +3 -3
- package/src/public/css/theme-system.css +98 -11
- package/src/public/hooks/index.ts +4 -0
- package/src/public/hooks/useAgentLoad.ts +105 -0
- package/src/public/hooks/useCodeMarkers.ts +101 -0
- package/src/public/hooks/useColorScheme.ts +25 -10
- package/src/public/hooks/useComplexity.ts +80 -0
- package/src/public/hooks/useDeadCode.ts +99 -0
- package/src/public/hooks/useDependencies.ts +82 -0
- package/src/public/hooks/useHealthScore.ts +69 -0
- package/src/public/hooks/useHotspots.ts +11 -1
- package/src/public/hooks/usePersona.ts +26 -3
- package/src/public/hooks/useSprint.ts +7 -1
- package/src/public/styles/tailwind.css +389 -83
- package/src/public/utils/messageFilters.ts +77 -6
- package/src/public/utils/slash-commands.ts +3 -35
- package/dist/hooks/cyclist-pretooluse-hook.d.ts +0 -60
- package/dist/hooks/cyclist-pretooluse-hook.d.ts.map +0 -1
- package/dist/hooks/cyclist-pretooluse-hook.js +0 -57
- package/dist/hooks/cyclist-pretooluse-hook.js.map +0 -1
- package/dist/hooks/pretooluse-hook.d.ts +0 -89
- package/dist/hooks/pretooluse-hook.d.ts.map +0 -1
- package/dist/hooks/pretooluse-hook.js +0 -235
- package/dist/hooks/pretooluse-hook.js.map +0 -1
- package/dist/notification-sound.d.ts +0 -59
- package/dist/notification-sound.d.ts.map +0 -1
- package/dist/notification-sound.js +0 -219
- package/dist/notification-sound.js.map +0 -1
- package/src/public/types/electron.d.ts +0 -18
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import React, { useState, useEffect, useMemo } from 'react';
|
|
2
|
+
import { Button } from '@/components/ui/button';
|
|
3
|
+
import { Progress } from '@/components/ui/progress';
|
|
4
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
5
|
+
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from '@/components/ui/collapsible';
|
|
6
|
+
import { ToolDialog } from './dialogs/ToolDialog';
|
|
7
|
+
import { ConfirmDialog, useConfirmDialog } from './ConfirmDialog';
|
|
8
|
+
import { useAgentLoad } from '../hooks/useAgentLoad.js';
|
|
9
|
+
import type { AgentLoadEntry } from '../hooks/useAgentLoad.js';
|
|
10
|
+
import { formatComponentName } from './panels/DebugPanel';
|
|
11
|
+
|
|
12
|
+
const SIDECAR_FILES = ['patterns.md', 'gotchas.md', 'decisions.md'] as const;
|
|
13
|
+
|
|
14
|
+
export interface AgentLoadDialogProps {
|
|
15
|
+
isOpen: boolean;
|
|
16
|
+
onClose: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function AgentLoadDialog({ isOpen, onClose }: AgentLoadDialogProps): React.ReactElement {
|
|
20
|
+
const { data, isLoading, error, refresh, pruneSidecar, pruneResult } = useAgentLoad();
|
|
21
|
+
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
|
|
22
|
+
const [pendingPrune, setPendingPrune] = useState<{ agent: string; file: string } | null>(null);
|
|
23
|
+
|
|
24
|
+
const { confirm, dialogProps } = useConfirmDialog({
|
|
25
|
+
title: 'Clear Sidecar',
|
|
26
|
+
message: pendingPrune
|
|
27
|
+
? `Reset ${pendingPrune.file} for ${pendingPrune.agent}? This will replace the sidecar with its default template.`
|
|
28
|
+
: '',
|
|
29
|
+
confirmLabel: 'Confirm',
|
|
30
|
+
isDanger: true,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (isOpen) {
|
|
35
|
+
refresh();
|
|
36
|
+
}
|
|
37
|
+
}, [isOpen, refresh]);
|
|
38
|
+
|
|
39
|
+
const sortedAgents = useMemo(() => {
|
|
40
|
+
if (!data) return [];
|
|
41
|
+
return [...data.agents].sort((a, b) => (b.totalTokens ?? 0) - (a.totalTokens ?? 0));
|
|
42
|
+
}, [data]);
|
|
43
|
+
|
|
44
|
+
const maxTokens = useMemo(() => {
|
|
45
|
+
if (!sortedAgents.length) return 0;
|
|
46
|
+
return sortedAgents[0]?.totalTokens ?? 0;
|
|
47
|
+
}, [sortedAgents]);
|
|
48
|
+
|
|
49
|
+
const handleRowClick = (agent: string) => {
|
|
50
|
+
setExpandedAgent((prev) => (prev === agent ? null : agent));
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const handleClear = async (agent: string, file: string) => {
|
|
54
|
+
setPendingPrune({ agent, file });
|
|
55
|
+
const confirmed = await confirm();
|
|
56
|
+
if (confirmed) {
|
|
57
|
+
await pruneSidecar(agent, file);
|
|
58
|
+
}
|
|
59
|
+
setPendingPrune(null);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const renderAgentRow = (entry: AgentLoadEntry) => {
|
|
63
|
+
const tokens = entry.totalTokens ?? 0;
|
|
64
|
+
const progressValue = maxTokens > 0 ? (tokens / maxTokens) * 100 : 0;
|
|
65
|
+
const isExpanded = expandedAgent === entry.agent;
|
|
66
|
+
// Color by token threshold: <3k green, <5k orange, >=5k red
|
|
67
|
+
const barColor = tokens >= 5000
|
|
68
|
+
? 'bg-[var(--status-error,#f14c4c)]'
|
|
69
|
+
: tokens >= 3000
|
|
70
|
+
? 'bg-[var(--status-warning,#cca700)]'
|
|
71
|
+
: 'bg-[var(--status-success,#4ec9b0)]';
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<Collapsible
|
|
75
|
+
key={entry.agent}
|
|
76
|
+
open={isExpanded}
|
|
77
|
+
onOpenChange={() => handleRowClick(entry.agent)}
|
|
78
|
+
>
|
|
79
|
+
<CollapsibleTrigger asChild>
|
|
80
|
+
<div
|
|
81
|
+
className="px-4 py-2 cursor-pointer hover:bg-muted/50 rounded-md transition-colors"
|
|
82
|
+
data-testid={`agent-row-${entry.agent}`}
|
|
83
|
+
>
|
|
84
|
+
<div className="flex items-center justify-between mb-1">
|
|
85
|
+
<span className="font-mono text-sm font-medium">{entry.agent}</span>
|
|
86
|
+
<span className="font-mono text-sm tabular-nums text-text-secondary">
|
|
87
|
+
{tokens.toLocaleString()}
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
<Progress
|
|
91
|
+
value={progressValue}
|
|
92
|
+
className="h-2.5 bg-[var(--border)]"
|
|
93
|
+
indicatorClassName={barColor}
|
|
94
|
+
/>
|
|
95
|
+
</div>
|
|
96
|
+
</CollapsibleTrigger>
|
|
97
|
+
<CollapsibleContent>
|
|
98
|
+
<div className="mx-4 mb-3 rounded-md bg-muted/30 p-3 space-y-1">
|
|
99
|
+
{entry.components && entry.components.length > 0 ? (
|
|
100
|
+
entry.components.map((comp) => (
|
|
101
|
+
<div key={comp.name} className="flex items-center justify-between text-xs text-text-secondary">
|
|
102
|
+
<span>{formatComponentName(comp.name)}</span>
|
|
103
|
+
<span className="tabular-nums">{comp.tokens.toLocaleString()}</span>
|
|
104
|
+
</div>
|
|
105
|
+
))
|
|
106
|
+
) : (
|
|
107
|
+
<div className="text-xs text-text-secondary">No component breakdown available</div>
|
|
108
|
+
)}
|
|
109
|
+
<div className="pt-2 border-t border-muted/50 mt-2">
|
|
110
|
+
<div className="text-xs font-medium mb-1.5 text-text-secondary">Sidecars</div>
|
|
111
|
+
<div className="flex gap-2">
|
|
112
|
+
{SIDECAR_FILES.map((file) => (
|
|
113
|
+
<Button
|
|
114
|
+
key={file}
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
className="text-xs h-6"
|
|
118
|
+
onClick={(e) => {
|
|
119
|
+
e.stopPropagation();
|
|
120
|
+
handleClear(entry.agent, file);
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
Clear {file}
|
|
124
|
+
</Button>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</CollapsibleContent>
|
|
130
|
+
</Collapsible>
|
|
131
|
+
);
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const renderContent = () => {
|
|
135
|
+
if (isLoading) {
|
|
136
|
+
return (
|
|
137
|
+
<div className="space-y-3 p-4">
|
|
138
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
139
|
+
<div key={i} data-testid={`skeleton-${i}`} className="space-y-1.5 px-4">
|
|
140
|
+
<div className="flex justify-between">
|
|
141
|
+
<Skeleton className="h-4 w-24" />
|
|
142
|
+
<Skeleton className="h-4 w-16" />
|
|
143
|
+
</div>
|
|
144
|
+
<Skeleton className="h-2.5 w-full rounded-full" />
|
|
145
|
+
</div>
|
|
146
|
+
))}
|
|
147
|
+
</div>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (error) {
|
|
152
|
+
return (
|
|
153
|
+
<div className="p-4 space-y-3">
|
|
154
|
+
<div className="p-4 rounded border border-[var(--status-error)]/20 bg-[var(--status-error)]/5 text-[var(--status-error)] text-sm">
|
|
155
|
+
{error.message}
|
|
156
|
+
</div>
|
|
157
|
+
<div className="text-center">
|
|
158
|
+
<Button variant="outline" size="sm" onClick={refresh}>
|
|
159
|
+
Retry
|
|
160
|
+
</Button>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!data) return null;
|
|
167
|
+
|
|
168
|
+
return (
|
|
169
|
+
<div className="space-y-0.5">
|
|
170
|
+
{pruneResult?.success && pruneResult.tokensFreed != null && (
|
|
171
|
+
<div className="text-xs text-green-600 px-4 py-1">
|
|
172
|
+
Freed {pruneResult.tokensFreed.toLocaleString()} tokens from {pruneResult.agent}/{pruneResult.file}
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
<div className="flex items-center justify-between px-4 py-2">
|
|
176
|
+
<span data-testid="cached-at" className="text-xs text-[var(--text-muted)]">
|
|
177
|
+
Cached: {new Date(data.cachedAt).toLocaleString()}
|
|
178
|
+
</span>
|
|
179
|
+
<Button variant="ghost" size="sm" className="text-xs h-6 text-[var(--text-muted)] hover:text-[var(--text-primary)]" onClick={refresh}>
|
|
180
|
+
Refresh
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
{sortedAgents.map((entry) => renderAgentRow(entry))}
|
|
184
|
+
</div>
|
|
185
|
+
);
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<>
|
|
190
|
+
<ToolDialog
|
|
191
|
+
open={isOpen}
|
|
192
|
+
onOpenChange={(open) => { if (!open) onClose(); }}
|
|
193
|
+
title="Agent Load Analysis"
|
|
194
|
+
description="Token usage breakdown for all agents"
|
|
195
|
+
className="max-w-2xl"
|
|
196
|
+
>
|
|
197
|
+
{renderContent()}
|
|
198
|
+
</ToolDialog>
|
|
199
|
+
<ConfirmDialog {...dialogProps} />
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -183,11 +183,9 @@ export function AgentPopup({ isOpen, onClose, currentRole, currentTheme }: Agent
|
|
|
183
183
|
<div className="agent-popup-header">
|
|
184
184
|
<h2 id="agent-popup-title" className="agent-popup-theme">
|
|
185
185
|
{themeData.themeName}
|
|
186
|
-
{themeData.tier
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
</Badge>
|
|
190
|
-
)}
|
|
186
|
+
<Badge variant="secondary" className={`tier-badge ${themeData.tier ? `tier-${themeData.tier.toLowerCase()}` : 'tier-unranked'}`}>
|
|
187
|
+
{themeData.tier || 'Unranked'}
|
|
188
|
+
</Badge>
|
|
191
189
|
</h2>
|
|
192
190
|
<Button
|
|
193
191
|
variant="ghost"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface SparklinePoint {
|
|
4
|
+
percent: number;
|
|
5
|
+
tokens: number;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ContextSparklineProps {
|
|
10
|
+
history: SparklinePoint[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function getBarColor(tokens: number): string {
|
|
14
|
+
if (tokens >= 50000) return 'var(--color-danger, #ef4444)';
|
|
15
|
+
if (tokens >= 5000) return 'var(--color-warning, #f59e0b)';
|
|
16
|
+
return 'var(--color-success, #22c55e)';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ContextSparkline({ history }: ContextSparklineProps): React.ReactElement | null {
|
|
20
|
+
if (history.length < 2) return null;
|
|
21
|
+
|
|
22
|
+
const W = 200;
|
|
23
|
+
const H = 32;
|
|
24
|
+
const PAD = 2;
|
|
25
|
+
const usable = H - PAD * 2;
|
|
26
|
+
const latest = history[history.length - 1];
|
|
27
|
+
const barWidth = W / history.length;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="context-sparkline" data-testid="context-sparkline">
|
|
31
|
+
<svg
|
|
32
|
+
viewBox={`0 0 ${W} ${H}`}
|
|
33
|
+
role="img"
|
|
34
|
+
aria-label={`Context usage trend: currently ${latest.percent}%`}
|
|
35
|
+
>
|
|
36
|
+
{history.map((p, i) => {
|
|
37
|
+
const barHeight = Math.max(1, (p.percent / 100) * usable);
|
|
38
|
+
const x = i * barWidth;
|
|
39
|
+
const y = PAD + usable - barHeight;
|
|
40
|
+
return (
|
|
41
|
+
<rect
|
|
42
|
+
key={i}
|
|
43
|
+
x={x}
|
|
44
|
+
y={y}
|
|
45
|
+
width={barWidth}
|
|
46
|
+
height={barHeight}
|
|
47
|
+
fill={getBarColor(p.tokens)}
|
|
48
|
+
opacity={0.8}
|
|
49
|
+
rx={0.5}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
})}
|
|
53
|
+
</svg>
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* ControlBar Component
|
|
3
3
|
*
|
|
4
|
-
* Provides Stop, Reset, Bell Mode,
|
|
4
|
+
* Provides Stop, Reset, Bell Mode, Relay Mode, and Agent Quick Picker controls.
|
|
5
5
|
* Story MSSCI-12729 - Stop/Reset Controls and Escape Key
|
|
6
6
|
* Story MSSCI-12275 - Bell Mode toggle
|
|
7
7
|
* Story MSSCI-12395 - Relay Mode toggle
|
|
8
|
+
* Story MSSCI-14762 - Quick agent picker in control bar
|
|
8
9
|
*
|
|
9
10
|
* Features:
|
|
10
11
|
* - Stop button visible only when Claude is running
|
|
11
12
|
* - Reset button always visible
|
|
13
|
+
* - Agent quick picker (lightweight dropdown for rapid agent switching)
|
|
12
14
|
* - Bell mode toggle (inject queued messages via PostToolUse hook)
|
|
13
15
|
* - Relay mode toggle (auto-handoff to next agent)
|
|
14
16
|
* - Escape key handler for stopping (single press = interrupt, double = force kill)
|
|
@@ -16,6 +18,7 @@
|
|
|
16
18
|
*/
|
|
17
19
|
|
|
18
20
|
import React, { useEffect, useRef, useCallback, useState, FocusEvent } from 'react';
|
|
21
|
+
import { BellRing, Zap, RotateCcw, UserCog } from 'lucide-react';
|
|
19
22
|
import { Button } from '@/components/ui/button';
|
|
20
23
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
21
24
|
import { useClaudeContext } from '../contexts/ClaudeContext';
|
|
@@ -40,6 +43,123 @@ function useFocusTracking() {
|
|
|
40
43
|
return { handleFocus, handleBlur, isFocused };
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
// =============================================================================
|
|
47
|
+
// Agent Quick Picker
|
|
48
|
+
// =============================================================================
|
|
49
|
+
|
|
50
|
+
interface ThemeAgent {
|
|
51
|
+
role: string;
|
|
52
|
+
character: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ThemeData {
|
|
57
|
+
agents: ThemeAgent[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function AgentQuickPicker({ currentAgent, onAgentSwitch }: { currentAgent: string | null; onAgentSwitch?: (role: string) => void }): React.ReactElement {
|
|
61
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
62
|
+
const [agents, setAgents] = useState<ThemeAgent[]>([]);
|
|
63
|
+
const pickerRef = useRef<HTMLDivElement>(null);
|
|
64
|
+
|
|
65
|
+
// Fetch agent list on mount
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
fetch('/api/theme-agents/full')
|
|
68
|
+
.then(res => res.ok ? res.json() : null)
|
|
69
|
+
.then((data: ThemeData | null) => {
|
|
70
|
+
if (data?.agents) {
|
|
71
|
+
setAgents(data.agents);
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.catch(() => {});
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// Close on outside click
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!isOpen) return;
|
|
80
|
+
|
|
81
|
+
const handleMouseDown = (e: MouseEvent) => {
|
|
82
|
+
if (pickerRef.current && !pickerRef.current.contains(e.target as Node)) {
|
|
83
|
+
setIsOpen(false);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
document.addEventListener('mousedown', handleMouseDown);
|
|
88
|
+
return () => document.removeEventListener('mousedown', handleMouseDown);
|
|
89
|
+
}, [isOpen]);
|
|
90
|
+
|
|
91
|
+
// Close on Escape
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!isOpen) return;
|
|
94
|
+
|
|
95
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
96
|
+
if (e.key === 'Escape') {
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
setIsOpen(false);
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
document.addEventListener('keydown', handleKeyDown, true);
|
|
103
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true);
|
|
104
|
+
}, [isOpen]);
|
|
105
|
+
|
|
106
|
+
const handleAgentClick = useCallback((agent: ThemeAgent) => {
|
|
107
|
+
if (agent.role === currentAgent) return;
|
|
108
|
+
onAgentSwitch?.(agent.role);
|
|
109
|
+
setIsOpen(false);
|
|
110
|
+
}, [currentAgent, onAgentSwitch]);
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<div className="agent-quick-picker-wrapper" ref={pickerRef}>
|
|
114
|
+
<Tooltip>
|
|
115
|
+
<TooltipTrigger asChild>
|
|
116
|
+
<Button
|
|
117
|
+
variant="ghost"
|
|
118
|
+
size="icon"
|
|
119
|
+
type="button"
|
|
120
|
+
className={`btn-toggle agent-picker-toggle ${isOpen ? 'active' : ''}`}
|
|
121
|
+
data-testid="agent-quick-picker"
|
|
122
|
+
onClick={() => setIsOpen(prev => !prev)}
|
|
123
|
+
aria-label="Switch agent"
|
|
124
|
+
aria-expanded={isOpen}
|
|
125
|
+
aria-haspopup="listbox"
|
|
126
|
+
>
|
|
127
|
+
<UserCog className="h-4 w-4" />
|
|
128
|
+
</Button>
|
|
129
|
+
</TooltipTrigger>
|
|
130
|
+
<TooltipContent>Switch Agent</TooltipContent>
|
|
131
|
+
</Tooltip>
|
|
132
|
+
|
|
133
|
+
{isOpen && (
|
|
134
|
+
<div
|
|
135
|
+
className="agent-quick-picker-dropdown"
|
|
136
|
+
data-testid="agent-quick-picker-dropdown"
|
|
137
|
+
role="listbox"
|
|
138
|
+
aria-label="Available agents"
|
|
139
|
+
>
|
|
140
|
+
{agents.map(agent => {
|
|
141
|
+
const isCurrent = agent.role === currentAgent;
|
|
142
|
+
return (
|
|
143
|
+
<div
|
|
144
|
+
key={agent.role}
|
|
145
|
+
className={`agent-quick-picker-option ${isCurrent ? 'current' : ''}`}
|
|
146
|
+
data-testid={`agent-option-${agent.role}`}
|
|
147
|
+
role="option"
|
|
148
|
+
aria-selected={isCurrent}
|
|
149
|
+
aria-label={`${agent.role} (${agent.character})`}
|
|
150
|
+
title={agent.character}
|
|
151
|
+
onClick={() => handleAgentClick(agent)}
|
|
152
|
+
>
|
|
153
|
+
<span className="agent-option-role">{agent.role}</span>
|
|
154
|
+
</div>
|
|
155
|
+
);
|
|
156
|
+
})}
|
|
157
|
+
</div>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
43
163
|
// =============================================================================
|
|
44
164
|
// Types
|
|
45
165
|
// =============================================================================
|
|
@@ -69,6 +189,8 @@ export interface ControlBarProps {
|
|
|
69
189
|
currentAgent?: string | null;
|
|
70
190
|
/** Called when TirePump button clicked */
|
|
71
191
|
onTirePump?: () => void;
|
|
192
|
+
/** Called when agent is selected from quick picker */
|
|
193
|
+
onAgentSwitch?: (role: string) => void;
|
|
72
194
|
}
|
|
73
195
|
|
|
74
196
|
// =============================================================================
|
|
@@ -88,6 +210,7 @@ export function ControlBar({
|
|
|
88
210
|
contextPercent = 0,
|
|
89
211
|
currentAgent = null,
|
|
90
212
|
onTirePump,
|
|
213
|
+
onAgentSwitch,
|
|
91
214
|
}: ControlBarProps): React.ReactElement {
|
|
92
215
|
const lastEscapeTime = useRef<number>(0);
|
|
93
216
|
const DOUBLE_PRESS_THRESHOLD = 500; // ms
|
|
@@ -134,8 +257,11 @@ export function ControlBar({
|
|
|
134
257
|
return (
|
|
135
258
|
<TooltipProvider delayDuration={300}>
|
|
136
259
|
<div className="control-bar" data-testid="control-bar">
|
|
137
|
-
{/* Mode toggles - Bell and Relay */}
|
|
260
|
+
{/* Mode toggles - Agent Picker, Bell, and Relay */}
|
|
138
261
|
<div className="control-bar-toggles">
|
|
262
|
+
{/* Agent Quick Picker */}
|
|
263
|
+
<AgentQuickPicker currentAgent={currentAgent} onAgentSwitch={onAgentSwitch} />
|
|
264
|
+
|
|
139
265
|
{/* Bell Mode Toggle */}
|
|
140
266
|
<Tooltip>
|
|
141
267
|
<TooltipTrigger asChild>
|
|
@@ -149,7 +275,7 @@ export function ControlBar({
|
|
|
149
275
|
aria-pressed={bellMode}
|
|
150
276
|
aria-label="Bell mode - inject queued messages via hook"
|
|
151
277
|
>
|
|
152
|
-
<
|
|
278
|
+
<BellRing className="h-4 w-4" />
|
|
153
279
|
</Button>
|
|
154
280
|
</TooltipTrigger>
|
|
155
281
|
<TooltipContent>Bell Mode: Inject queued messages during tool use (Cmd+B)</TooltipContent>
|
|
@@ -168,7 +294,7 @@ export function ControlBar({
|
|
|
168
294
|
aria-pressed={relayMode}
|
|
169
295
|
aria-label="Relay mode - auto-handoff to next agent"
|
|
170
296
|
>
|
|
171
|
-
<
|
|
297
|
+
<Zap className="h-4 w-4" />
|
|
172
298
|
</Button>
|
|
173
299
|
</TooltipTrigger>
|
|
174
300
|
<TooltipContent>Relay Mode: Auto-handoff to next agent (Cmd+4)</TooltipContent>
|
|
@@ -187,7 +313,7 @@ export function ControlBar({
|
|
|
187
313
|
disabled={!currentAgent}
|
|
188
314
|
aria-label="TirePump: Clear context and reload agent"
|
|
189
315
|
>
|
|
190
|
-
<
|
|
316
|
+
<RotateCcw className="h-4 w-4" />
|
|
191
317
|
</Button>
|
|
192
318
|
</TooltipTrigger>
|
|
193
319
|
<TooltipContent>{currentAgent ? `TirePump: Clear context (${contextPercent}%) and reload ${currentAgent}` : 'TirePump: No agent loaded'}</TooltipContent>
|
|
@@ -265,6 +391,8 @@ interface UseControlBarResult {
|
|
|
265
391
|
handleRelayModeChange: (enabled: boolean) => void;
|
|
266
392
|
/** Handle TirePump action */
|
|
267
393
|
handleTirePump: () => void;
|
|
394
|
+
/** Handle agent switch from quick picker */
|
|
395
|
+
handleAgentSwitch: (role: string) => void;
|
|
268
396
|
}
|
|
269
397
|
|
|
270
398
|
export function useControlBar(): UseControlBarResult {
|
|
@@ -276,7 +404,7 @@ export function useControlBar(): UseControlBarResult {
|
|
|
276
404
|
const [currentAgent, setCurrentAgent] = useState<string | null>(null);
|
|
277
405
|
|
|
278
406
|
// Claude context for WebSocket communication
|
|
279
|
-
const { abort, clear, clearAndReload, onMessage, onComplete, onError, isConnected } = useClaudeContext();
|
|
407
|
+
const { abort, clear, clearAndReload, send, onMessage, onComplete, onError, isConnected } = useClaudeContext();
|
|
280
408
|
|
|
281
409
|
// Load initial settings and listen for changes (using REST/WebSocket, not IPC)
|
|
282
410
|
useEffect(() => {
|
|
@@ -483,6 +611,11 @@ export function useControlBar(): UseControlBarResult {
|
|
|
483
611
|
}
|
|
484
612
|
}, [currentAgent, clearAndReload]);
|
|
485
613
|
|
|
614
|
+
// Agent quick picker: send /{role} command
|
|
615
|
+
const handleAgentSwitch = useCallback((role: string) => {
|
|
616
|
+
send(`/${role}`);
|
|
617
|
+
}, [send]);
|
|
618
|
+
|
|
486
619
|
return {
|
|
487
620
|
isRunning,
|
|
488
621
|
isStopping,
|
|
@@ -496,6 +629,7 @@ export function useControlBar(): UseControlBarResult {
|
|
|
496
629
|
handleBellModeChange,
|
|
497
630
|
handleRelayModeChange,
|
|
498
631
|
handleTirePump,
|
|
632
|
+
handleAgentSwitch,
|
|
499
633
|
};
|
|
500
634
|
}
|
|
501
635
|
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
|
4
|
+
} from '@/components/ui/dialog';
|
|
5
|
+
import { Badge } from '@/components/ui/badge';
|
|
6
|
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import { useDeadCode, type StaleFile, type UnusedExport } from '@/hooks/useDeadCode';
|
|
9
|
+
|
|
10
|
+
export interface DeadCodeDialogProps {
|
|
11
|
+
isOpen: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
days?: number;
|
|
14
|
+
repo?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type TabId = 'stale' | 'exports';
|
|
18
|
+
type SortDir = 'asc' | 'desc';
|
|
19
|
+
|
|
20
|
+
export function DeadCodeDialog({ isOpen, onClose, days = 180, repo }: DeadCodeDialogProps) {
|
|
21
|
+
const { data, isLoading, error, refresh } = useDeadCode({ days, repo, layer: 'all' });
|
|
22
|
+
const [activeTab, setActiveTab] = useState<TabId>('stale');
|
|
23
|
+
const [staleSortField, setStaleSortField] = useState<keyof StaleFile>('days_since_last_commit');
|
|
24
|
+
const [staleSortDir, setStaleSortDir] = useState<SortDir>('desc');
|
|
25
|
+
const [exportSortField, setExportSortField] = useState<keyof UnusedExport>('file');
|
|
26
|
+
const [exportSortDir, setExportSortDir] = useState<SortDir>('asc');
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (isOpen) refresh();
|
|
30
|
+
}, [isOpen, refresh]);
|
|
31
|
+
|
|
32
|
+
const handleStaleSort = useCallback((field: keyof StaleFile) => {
|
|
33
|
+
setStaleSortDir(prev => staleSortField === field && prev === 'desc' ? 'asc' : 'desc');
|
|
34
|
+
setStaleSortField(field);
|
|
35
|
+
}, [staleSortField]);
|
|
36
|
+
|
|
37
|
+
const handleExportSort = useCallback((field: keyof UnusedExport) => {
|
|
38
|
+
setExportSortDir(prev => exportSortField === field && prev === 'desc' ? 'asc' : 'desc');
|
|
39
|
+
setExportSortField(field);
|
|
40
|
+
}, [exportSortField]);
|
|
41
|
+
|
|
42
|
+
const staleCount = data?.stale_file_count ?? data?.stale_files?.length ?? 0;
|
|
43
|
+
const exportCount = data?.unused_export_count ?? data?.unused_exports?.length ?? 0;
|
|
44
|
+
|
|
45
|
+
const sortedStaleFiles = useMemo(() => {
|
|
46
|
+
const files = [...(data?.stale_files || [])];
|
|
47
|
+
files.sort((a, b) => {
|
|
48
|
+
const aVal = a[staleSortField];
|
|
49
|
+
const bVal = b[staleSortField];
|
|
50
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
51
|
+
return staleSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
52
|
+
}
|
|
53
|
+
const aStr = String(aVal);
|
|
54
|
+
const bStr = String(bVal);
|
|
55
|
+
return staleSortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
|
56
|
+
});
|
|
57
|
+
return files;
|
|
58
|
+
}, [data?.stale_files, staleSortField, staleSortDir]);
|
|
59
|
+
|
|
60
|
+
const sortedExports = useMemo(() => {
|
|
61
|
+
const exports = [...(data?.unused_exports || [])];
|
|
62
|
+
exports.sort((a, b) => {
|
|
63
|
+
const aVal = a[exportSortField];
|
|
64
|
+
const bVal = b[exportSortField];
|
|
65
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
|
66
|
+
return exportSortDir === 'asc' ? aVal - bVal : bVal - aVal;
|
|
67
|
+
}
|
|
68
|
+
const aStr = String(aVal);
|
|
69
|
+
const bStr = String(bVal);
|
|
70
|
+
return exportSortDir === 'asc' ? aStr.localeCompare(bStr) : bStr.localeCompare(aStr);
|
|
71
|
+
});
|
|
72
|
+
return exports;
|
|
73
|
+
}, [data?.unused_exports, exportSortField, exportSortDir]);
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose(); }}>
|
|
77
|
+
<DialogContent className="max-w-4xl max-h-[80vh]">
|
|
78
|
+
<DialogHeader>
|
|
79
|
+
<DialogTitle>Dead Code Analysis</DialogTitle>
|
|
80
|
+
<DialogDescription>
|
|
81
|
+
Diagnostic report — files and exports with no recent activity.
|
|
82
|
+
</DialogDescription>
|
|
83
|
+
</DialogHeader>
|
|
84
|
+
|
|
85
|
+
{/* Tab bar */}
|
|
86
|
+
<div className="flex gap-4 border-b border-[var(--border)]">
|
|
87
|
+
<button
|
|
88
|
+
onClick={() => setActiveTab('stale')}
|
|
89
|
+
className={cn('pb-2 text-sm', activeTab === 'stale' ? 'border-b-2 border-[var(--accent)] text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]')}
|
|
90
|
+
>
|
|
91
|
+
Stale Files <Badge variant="secondary" className="ml-1">{staleCount}</Badge>
|
|
92
|
+
</button>
|
|
93
|
+
<button
|
|
94
|
+
onClick={() => setActiveTab('exports')}
|
|
95
|
+
className={cn('pb-2 text-sm', activeTab === 'exports' ? 'border-b-2 border-[var(--accent)] text-[var(--text-primary)] font-medium' : 'text-[var(--text-muted)]')}
|
|
96
|
+
>
|
|
97
|
+
Unused Exports <Badge variant="secondary" className="ml-1">{exportCount}</Badge>
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Content area */}
|
|
102
|
+
<ScrollArea className="h-[50vh]">
|
|
103
|
+
{isLoading && <div className="text-center py-12 text-[var(--text-muted)]">Analyzing...</div>}
|
|
104
|
+
{error && (
|
|
105
|
+
<div className="m-4 p-4 rounded border border-[var(--status-error)]/20 bg-[var(--status-error)]/5 text-[var(--status-error)] text-sm">
|
|
106
|
+
{error.message}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
{data && activeTab === 'stale' && (
|
|
110
|
+
sortedStaleFiles.length === 0 ? (
|
|
111
|
+
<div className="text-center py-12 text-[var(--text-muted)]">No stale files found</div>
|
|
112
|
+
) : (
|
|
113
|
+
<table className="w-full text-sm">
|
|
114
|
+
<thead>
|
|
115
|
+
<tr role="row" className="border-b border-[var(--border)]">
|
|
116
|
+
<th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('path')}>File</th>
|
|
117
|
+
<th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('days_since_last_commit')}>Days Stale</th>
|
|
118
|
+
<th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('size_bytes')}>Size</th>
|
|
119
|
+
<th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleStaleSort('last_commit_date')}>Last Commit</th>
|
|
120
|
+
</tr>
|
|
121
|
+
</thead>
|
|
122
|
+
<tbody>
|
|
123
|
+
{sortedStaleFiles.map((file) => (
|
|
124
|
+
<tr key={file.path} role="row" className="text-[var(--text-primary)]">
|
|
125
|
+
<td className="py-1.5 font-mono text-xs">{file.path}</td>
|
|
126
|
+
<td className="py-1.5 text-right tabular-nums font-mono">{file.days_since_last_commit}</td>
|
|
127
|
+
<td className="py-1.5 text-right tabular-nums font-mono">{formatBytes(file.size_bytes)}</td>
|
|
128
|
+
<td className="py-1.5">{file.last_commit_date ? new Date(file.last_commit_date).toLocaleDateString() : '—'}</td>
|
|
129
|
+
</tr>
|
|
130
|
+
))}
|
|
131
|
+
</tbody>
|
|
132
|
+
</table>
|
|
133
|
+
)
|
|
134
|
+
)}
|
|
135
|
+
{data && activeTab === 'exports' && (
|
|
136
|
+
sortedExports.length === 0 ? (
|
|
137
|
+
<div className="text-center py-12 text-[var(--text-muted)]">No unused exports found</div>
|
|
138
|
+
) : (
|
|
139
|
+
<table className="w-full text-sm">
|
|
140
|
+
<thead>
|
|
141
|
+
<tr role="row" className="border-b border-[var(--border)]">
|
|
142
|
+
<th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('file')}>File</th>
|
|
143
|
+
<th className="text-left pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('symbol')}>Export</th>
|
|
144
|
+
<th className="text-right pb-2 cursor-pointer select-none text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]" onClick={() => handleExportSort('line')}>Line</th>
|
|
145
|
+
</tr>
|
|
146
|
+
</thead>
|
|
147
|
+
<tbody>
|
|
148
|
+
{sortedExports.map((exp) => (
|
|
149
|
+
<tr key={`${exp.file}:${exp.symbol}`} role="row" className="text-[var(--text-primary)]">
|
|
150
|
+
<td className="py-1.5 font-mono text-xs">{exp.file}</td>
|
|
151
|
+
<td className="py-1.5">{exp.symbol}</td>
|
|
152
|
+
<td className="py-1.5 text-right tabular-nums font-mono">{exp.line}</td>
|
|
153
|
+
</tr>
|
|
154
|
+
))}
|
|
155
|
+
</tbody>
|
|
156
|
+
</table>
|
|
157
|
+
)
|
|
158
|
+
)}
|
|
159
|
+
</ScrollArea>
|
|
160
|
+
</DialogContent>
|
|
161
|
+
</Dialog>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function formatBytes(bytes: number): string {
|
|
166
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
167
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
168
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
169
|
+
}
|